This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 7ebf8c2751a branch-4.1: [feature](cloud) Support declaring
compute_group on routine load and async MV (#67010)
7ebf8c2751a is described below
commit 7ebf8c2751a634d4b3cadcebb13f82260490e9f7
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Sep 7 11:11:01 2026 +0800
branch-4.1: [feature](cloud) Support declaring compute_group on routine
load and async MV (#67010)
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary:
Background jobs cannot be pinned to a specific compute group today. A
routine load
job silently snapshots whatever compute group the creating session
happened to be
on, and an async materialized view has no compute group property at all:
automatic
refreshes run wherever `admin` resolves to, while a manual `REFRESH`
borrows the
triggering session's group. So the same MV can refresh in two different
places
depending on who triggered it, and there is no way to isolate one job's
resources
from another's.
This PR adds a `compute_group` property to `CREATE`/`ALTER ROUTINE LOAD`
and to
async materialized views, so a job can be pinned explicitly.
This is a transitional binding ahead of the full
`(owner, compute_group, workload_group)` model. The property name and
its value
space are deliberately identical to that design, and the key is written
only when
the user actually declared it, so metadata written here is read back as
an explicit
pin later on with no conversion:
- the declaration lives in the already existing property maps
(`RoutineLoadJob.jobProperties`, `MTMV.mvProperties`), so no new
persisted field,
no journal type and no `FeMetaVersion` bump is needed;
- an absent key keeps the existing implicit resolution untouched, which
is also how
a pinned group is told apart from an inherited one;
- `DEFAULT` is rejected because it is reserved to mean "follow the
owner's default
group", and a group literally named `DEFAULT` would otherwise be
silently
reinterpreted after an upgrade;
- non-cloud is out of scope for now and rejected at `CREATE`/`ALTER`, so
non-cloud
metadata never carries the key.
**Routine load** resolves the effective cluster in `getCloudCluster()`,
which every
consumer already goes through: backend selection, the thrift task sent
to BE, the
workload group namespace, the plan context used by `OlapTableSink`, and
the
`ComputeGroup` column of `SHOW ROUTINE LOAD`. `ALTER` only updates the
property map
and is covered by the existing edit log, so replay needs no change.
**Async MVs** resolve it in `MTMVTask.setComputeGroup()`, the single
point where the
group is applied, so every trigger path is covered including the
scheduler one. A
declared group also wins over the triggering session for a manual
`REFRESH`, so the
same MV no longer refreshes in two different places depending on who
triggered it.
MVs that declare nothing keep borrowing the session's group, so existing
MVs are
unaffected.
The binding is also re-checked before every task, not only at create
time, because the
groups can be dropped and the owner's privileges revoked while a job
keeps running:
- **routine load** checks against the job's owner
(`RoutineLoadJob.userIdentity`, the
real creator), so revoking that user's `USAGE` on the pinned compute
group fails the
next task and pauses the job with the real reason, instead of surfacing
later as
"no available BE found";
- **async MVs** run the same check. Their refresh identity is still the
hardcoded
`admin`, so the privilege half always passes today; the existence half
is what has
teeth. Once an MV carries a real owner, passing that owner in is the
only change
needed.
Only the **explicitly declared** compute group is re-checked. A job that
declared none
is bound to whatever cluster its creating session happened to be on -
something the
user never chose - so putting that implicit binding under a new
privilege check would
start pausing jobs that predate this feature. Workload group behaviour
is unchanged:
routine load already re-checked it per task against the same owner.
Two correctness fixes fall out of this:
- `CREATE ROUTINE LOAD` validated the workload group against the
session's compute
group. Since a workload group lives in a compute group's namespace, that
check has
to run against the declared group, otherwise a valid combination is
rejected and an
invalid one only fails on the first task.
- `RoutineLoadJob.plan()` set the plan context from the raw snapshot
field rather
than the getter, which would have sent the write path to the creating
session's
group while the tasks ran in the declared one.
- `ComputeGroupMgr.getComputeGroupByName()` builds its "not found"
message from the
thread-local `ConnectContext`, which the routine load scheduler and the
MV task
runner do not have, so a dropped group would have surfaced as an NPE
there. The
runtime check therefore tests existence through `getCloudClusterNames()`
instead.
Usage:
```sql
CREATE ROUTINE LOAD db.job ON tbl
COLUMNS TERMINATED BY ","
PROPERTIES ("compute_group" = "cg_etl")
FROM KAFKA (...);
ALTER ROUTINE LOAD FOR db.job PROPERTIES ("compute_group" = "cg_etl_2");
CREATE MATERIALIZED VIEW mv
BUILD DEFERRED REFRESH AUTO ON MANUAL
DISTRIBUTED BY RANDOM BUCKETS 2
PROPERTIES ("replication_num" = "1", "compute_group" = "cg_batch")
AS SELECT k1, k2 FROM tbl;
ALTER MATERIALIZED VIEW mv SET ("compute_group" = "cg_batch_2");
```
### Release note
Support declaring `compute_group` on routine load jobs and async
materialized views
in cloud mode, so a background job can be pinned to a specific compute
group instead
of inheriting one implicitly.
### Check List (For Author)
- Test
- [x] Regression test
- [x] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- Behavior changed:
- [ ] No.
- [x] Yes, twice, both scoped to jobs that declare the new property:
1. A materialized view that declares `compute_group` now refreshes in
that
group even when a manual `REFRESH` is triggered from a session on a
different compute group. MVs that do not declare the property are
unaffected and keep borrowing the session's group.
2. A job pinned to a compute group now fails its next task if that group
is
dropped or if its owner's `USAGE` on it is revoked. Jobs that declare no
compute group are unaffected.
- Does this need documentation?
- [ ] No.
- [x] Yes. The new `compute_group` property on routine load and async MV
needs a
doc entry, including that it is cloud-mode only for now and that
`DEFAULT` is
reserved.
### Check List (For Reviewer who merge this PR)
- [x] Confirm the release note
- [x] Confirm test cases
- [x] Confirm document
- [x] Add branch pick label
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../main/java/org/apache/doris/catalog/MTMV.java | 18 +
.../doris/cloud/load/CloudRoutineLoadManager.java | 7 +-
.../apache/doris/common/util/PropertyAnalyzer.java | 1 +
.../apache/doris/job/extensions/mtmv/MTMVTask.java | 41 +-
.../doris/load/routineload/KafkaTaskInfo.java | 4 +
.../doris/load/routineload/RoutineLoadJob.java | 35 +-
.../doris/load/routineload/RoutineLoadManager.java | 5 +-
.../load/routineload/RoutineLoadTaskScheduler.java | 17 +-
.../org/apache/doris/mtmv/MTMVPropertyUtil.java | 17 +
.../plans/commands/AlterRoutineLoadCommand.java | 16 +-
.../plans/commands/info/CreateRoutineLoadInfo.java | 45 +-
.../java/org/apache/doris/qe/ConnectContext.java | 5 +
.../computegroup/ComputeGroupBindingUtil.java | 161 +++++++
.../routineload/RoutineLoadComputeGroupTest.java | 499 +++++++++++++++++++++
.../apache/doris/mtmv/MTMVComputeGroupTest.java | 181 ++++++++
.../computegroup/ComputeGroupBindingUtilTest.java | 238 ++++++++++
.../test_compute_group_binding_isolation.groovy | 133 ++++++
.../test_routine_load_compute_group.groovy | 286 ++++++++++++
.../suites/mtmv_p0/test_mtmv_compute_group.groovy | 142 ++++++
19 files changed, 1837 insertions(+), 14 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
index 82955035282..fe2cf90f74f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
@@ -320,6 +320,24 @@ public class MTMV extends OlapTable {
}
}
+ /**
+ * The compute group explicitly declared on this MV, empty when the user
did not declare one.
+ * An empty result keeps the existing implicit resolution (admin's group
for auto refresh,
+ * the session's group for a manual REFRESH).
+ */
+ public Optional<String> getComputeGroup() {
+ readMvLock();
+ try {
+ if
(mvProperties.containsKey(PropertyAnalyzer.PROPERTIES_COMPUTE_GROUP) &&
!StringUtils
+
.isEmpty(mvProperties.get(PropertyAnalyzer.PROPERTIES_COMPUTE_GROUP))) {
+ return
Optional.of(mvProperties.get(PropertyAnalyzer.PROPERTIES_COMPUTE_GROUP));
+ }
+ return Optional.empty();
+ } finally {
+ readMvUnlock();
+ }
+ }
+
public boolean isUseForRewrite() {
readMvLock();
try {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/load/CloudRoutineLoadManager.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/load/CloudRoutineLoadManager.java
index cee1a079424..e7257475441 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/cloud/load/CloudRoutineLoadManager.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/cloud/load/CloudRoutineLoadManager.java
@@ -40,7 +40,12 @@ public class CloudRoutineLoadManager extends
RoutineLoadManager {
@Override
public void addRoutineLoadJob(RoutineLoadJob routineLoadJob, String
dbName, String tableName)
throws UserException {
- if (!Strings.isNullOrEmpty(ConnectContext.get().getCloudCluster())) {
+ // When the job declares a compute group explicitly, that declaration
is the binding and the
+ // session's cluster must not overwrite it.
+ if (!Strings.isNullOrEmpty(routineLoadJob.getDeclaredComputeGroup())) {
+ LOG.info("routine load job {} pinned to declared compute group {}",
+ routineLoadJob.getName(),
routineLoadJob.getDeclaredComputeGroup());
+ } else if
(!Strings.isNullOrEmpty(ConnectContext.get().getCloudCluster())) {
routineLoadJob.setCloudCluster(ConnectContext.get().getCloudCluster());
} else {
throw new UserException("cloud cluster is empty, please specify
cloud cluster");
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/common/util/PropertyAnalyzer.java
b/fe/fe-core/src/main/java/org/apache/doris/common/util/PropertyAnalyzer.java
index 631bdfd812d..56edd5a4492 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/common/util/PropertyAnalyzer.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/common/util/PropertyAnalyzer.java
@@ -222,6 +222,7 @@ public class PropertyAnalyzer {
"async_mv.query_rewrite.consistency_relaxed_tables";
public static final String PROPERTIES_REFRESH_PARTITION_NUM =
"refresh_partition_num";
public static final String PROPERTIES_WORKLOAD_GROUP = "workload_group";
+ public static final String PROPERTIES_COMPUTE_GROUP = "compute_group";
public static final String PROPERTIES_PARTITION_SYNC_LIMIT =
"partition_sync_limit";
public static final String PROPERTIES_PARTITION_TIME_UNIT =
"partition_sync_time_unit";
public static final String PROPERTIES_PARTITION_DATE_FORMAT =
"partition_date_format";
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
index f3dbabf97c9..ef84d6071ca 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
@@ -66,6 +66,7 @@ import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.QeProcessorImpl;
import org.apache.doris.qe.QueryState.MysqlStateType;
import org.apache.doris.qe.StmtExecutor;
+import org.apache.doris.resource.computegroup.ComputeGroupBindingUtil;
import org.apache.doris.system.SystemInfoService;
import org.apache.doris.thrift.TCell;
import org.apache.doris.thrift.TRow;
@@ -338,6 +339,7 @@ public class MTMVTask extends AbstractTask {
try {
setComputeGroup(ctx);
recordComputeGroup(ctx);
+ checkComputeGroupBeforeTask(ctx);
installTaskSnapshots(statementContext);
TUniqueId queryId = generateQueryId();
lastQueryId = DebugUtil.printId(queryId);
@@ -376,6 +378,10 @@ public class MTMVTask extends AbstractTask {
private ConnectContext createTaskContext() {
ConnectContext ctx = MTMVPlanUtil.createMTMVContext(
mtmv, MTMVPlanUtil.DISABLE_RULES_WHEN_RUN_MTMV_TASK);
+ // The planning done on this context (base table resolution, partition
calculation) must see
+ // the same compute group the refresh will execute in, otherwise the
workload group would be
+ // looked up in a different namespace.
+ setComputeGroup(ctx);
ctx.setStatementContext(new StatementContext());
return ctx;
}
@@ -405,10 +411,39 @@ public class MTMVTask extends AbstractTask {
}
private void setComputeGroup(ConnectContext ctx) {
- String taskComputeGroup = taskContext.getComputeGroup();
- if (Config.isCloudMode() && !Strings.isNullOrEmpty(taskComputeGroup)) {
- ctx.setCloudCluster(taskComputeGroup);
+ // A compute group declared on the MV pins every refresh, automatic or
manual, to that group.
+ // Only when the MV declares nothing does a manual REFRESH keep
borrowing the session's group,
+ // which is the behaviour every existing MV keeps.
+ String declared = mtmv == null ? null :
mtmv.getComputeGroup().orElse(null);
+ String effective = declared;
+ // A task read back from meta carries no taskContext, which the class
already tolerates
+ // elsewhere, so the session's group is only consulted when there is
one.
+ if (Strings.isNullOrEmpty(effective) && taskContext != null) {
+ effective = taskContext.getComputeGroup();
}
+ if (!Strings.isNullOrEmpty(effective)) {
+ ctx.setCloudCluster(effective);
+ }
+ }
+
+ /**
+ * Re-checks the declared compute group before the refresh runs: it can be
dropped and its
+ * privileges revoked while the MV exists, and without this the refresh
would fail later with an
+ * unrelated message.
+ *
+ * <p>The identity used here is whatever the refresh actually runs as,
which today is the
+ * hardcoded {@code admin} (see {@link
MTMVPlanUtil#createBasicMvContext}). That makes the
+ * privilege half of the check always pass; the existence half is what has
teeth right now. Once
+ * an MV carries a real owner, passing that owner here is the only change
needed.
+ */
+ private void checkComputeGroupBeforeTask(ConnectContext ctx) throws
UserException {
+ if (mtmv == null) {
+ return;
+ }
+ // Only the explicitly declared compute group is re-checked; an MV
that declares none borrows
+ // the session's group, which the user never chose for it.
+
ComputeGroupBindingUtil.checkComputeGroupBeforeTask(ctx.getCurrentUserIdentity(),
+ mtmv.getComputeGroup().orElse(null));
}
private void recordComputeGroup(ConnectContext ctx) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/KafkaTaskInfo.java
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/KafkaTaskInfo.java
index 6b8c7636a00..78d4312812f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/KafkaTaskInfo.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/KafkaTaskInfo.java
@@ -82,6 +82,10 @@ public class KafkaTaskInfo extends RoutineLoadTaskInfo {
public TRoutineLoadTask createRoutineLoadTask() throws UserException {
KafkaRoutineLoadJob routineLoadJob = (KafkaRoutineLoadJob)
routineLoadManager.getJob(jobId);
+ // The declared compute group is re-checked before every task, but
that happens earlier, in
+ // RoutineLoadTaskScheduler#scheduleOneTask: it has to run before
backend allocation and
+ // before beginTxn, neither of which has happened by the time this
method is called.
+
// init tRoutineLoadTask and create plan fragment
TRoutineLoadTask tRoutineLoadTask = new TRoutineLoadTask();
TUniqueId queryId = new TUniqueId(id.getMostSignificantBits(),
id.getLeastSignificantBits());
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
index 51c2ca42a4d..c0f98aa2960 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
@@ -129,6 +129,7 @@ public abstract class RoutineLoadJob
protected static final String STAR_STRING = "*";
public static final String WORKLOAD_GROUP = "workload_group";
+ public static final String COMPUTE_GROUP = "compute_group";
@Getter
@Setter
@@ -434,6 +435,13 @@ public abstract class RoutineLoadJob
if (!StringUtils.isEmpty(info.getWorkloadGroupName())) {
jobProperties.put(WORKLOAD_GROUP, info.getWorkloadGroupName());
}
+
+ // Only write the key when the user declared it. An absent key means
"not declared" and
+ // keeps the existing implicit resolution, which is also what later
versions rely on to
+ // tell a pinned group apart from an inherited one.
+ if (!StringUtils.isEmpty(info.getComputeGroupName())) {
+ jobProperties.put(COMPUTE_GROUP, info.getComputeGroupName());
+ }
}
protected void setRoutineLoadDesc(RoutineLoadDesc routineLoadDesc) {
@@ -548,6 +556,11 @@ public abstract class RoutineLoadJob
return jobProperties.get(WORKLOAD_GROUP);
}
+ // The compute group explicitly declared on the job, or null when the user
did not declare one.
+ public String getDeclaredComputeGroup() {
+ return jobProperties.get(COMPUTE_GROUP);
+ }
+
public JobState getState() {
return state;
}
@@ -795,7 +808,12 @@ public abstract class RoutineLoadJob
}
public String getCloudCluster() {
- return cloudCluster;
+ // An explicitly declared compute group wins over the cluster
snapshotted from the session
+ // at create time. Keeping this in the getter (instead of writing both
places) means every
+ // caller follows the declaration, and ALTER only has to update
jobProperties, which is
+ // already covered by the existing edit log.
+ String declared = getDeclaredComputeGroup();
+ return StringUtils.isEmpty(declared) ? cloudCluster : declared;
}
public int getSizeOfRoutineLoadTaskInfoList() {
@@ -1056,13 +1074,16 @@ public abstract class RoutineLoadJob
table.readLock();
try {
if (Config.isCloudMode()) {
+ // Use the effective cluster, not the raw snapshot field: this
context is what
+ // OlapTableSink uses to pick the backends the load writes to.
+ String effectiveCluster = getCloudCluster();
if (ConnectContext.get() == null) {
ConnectContext ctx = new ConnectContext();
ctx.setThreadLocalInfo();
- ctx.setCloudCluster(cloudCluster);
+ ctx.setCloudCluster(effectiveCluster);
needCleanCtx = true;
} else {
- ConnectContext.get().setCloudCluster(cloudCluster);
+ ConnectContext.get().setCloudCluster(effectiveCluster);
}
ConnectContext.get().setCurrentUserIdentity(this.getUserIdentity());
} else {
@@ -1664,7 +1685,9 @@ public abstract class RoutineLoadJob
}
public String getClusterInfo() {
- return Strings.nullToEmpty(cloudCluster);
+ // SHOW ROUTINE LOAD must display the cluster the job actually runs
in, i.e. the declared
+ // compute group when there is one.
+ return Strings.nullToEmpty(getCloudCluster());
}
// check the correctness of commit info
@@ -2006,6 +2029,10 @@ public abstract class RoutineLoadJob
CreateRoutineLoadCommand command = (CreateRoutineLoadCommand)
nereidsParser.parseSingle(
origStmt.originStmt);
CreateRoutineLoadInfo createRoutineLoadInfo =
command.getCreateRoutineLoadInfo();
+ // This re-parse only rebuilds the RoutineLoadDesc; it must
not re-check resources
+ // that can legitimately have disappeared since the job was
created, because the
+ // catch below turns any failure into the final CANCELLED
state.
+ createRoutineLoadInfo.setReplay(true);
// If tableId is set, resolve the current table name by ID so
that
// table rename / SWAP TABLE won't cause replay to fail with
stale name in origStmt.
if (!isMultiTable && tableId != 0) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadManager.java
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadManager.java
index de52bb8d459..6ef436e0b37 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadManager.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadManager.java
@@ -505,8 +505,9 @@ public class RoutineLoadManager implements Writable {
if (availableBeIds.isEmpty()) {
RoutineLoadJob job = getJob(jobId);
if (job != null) {
- String msg = "no available BE found for job " + jobId + ",
cluster Name {}, " + job.getCloudCluster()
- + "please check the BE status and user's cluster or
tags";
+ String msg = "no available BE found for job " + jobId + ",
cluster name: "
+ + job.getCloudCluster()
+ + ", please check the BE status and user's cluster or
tags";
job.updateState(RoutineLoadJob.JobState.PAUSED,
new ErrorReason(InternalErrorCode.INTERNAL_ERR, msg),
false /* not replay */);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java
index e1c4ce2b3dc..1f20a9864ff 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java
@@ -30,6 +30,7 @@ import org.apache.doris.common.util.LogBuilder;
import org.apache.doris.common.util.LogKey;
import org.apache.doris.common.util.MasterDaemon;
import org.apache.doris.load.routineload.RoutineLoadJob.JobState;
+import org.apache.doris.resource.computegroup.ComputeGroupBindingUtil;
import org.apache.doris.system.Backend;
import org.apache.doris.thrift.BackendService;
import org.apache.doris.thrift.TNetworkAddress;
@@ -135,9 +136,23 @@ public class RoutineLoadTaskScheduler extends MasterDaemon
{
}
try {
- if
(routineLoadManager.getJob(routineLoadTaskInfo.getJobId()).isFinal()) {
+ RoutineLoadJob job =
routineLoadManager.getJob(routineLoadTaskInfo.getJobId());
+ if (job.isFinal()) {
return;
}
+
+ // The compute group the job declared can be dropped and the
owner's USAGE on it revoked
+ // while the job keeps running, so it is re-checked before every
task.
+ //
+ // This has to happen here rather than while the task is being
built. Backend allocation
+ // below resolves the backends through that same compute group, so
a missing group would
+ // otherwise pause the job with a generic "no available BE found"
before this check was
+ // ever reached, which is exactly the misleading message it exists
to replace. Running it
+ // first also keeps a failing check from leaving a transaction
behind, because beginTxn
+ // has not happened yet.
+
ComputeGroupBindingUtil.checkComputeGroupBeforeTask(job.getUserIdentity(),
+ job.getDeclaredComputeGroup());
+
// check if topic has more data to consume
if (!routineLoadTaskInfo.hasMoreDataToConsume()) {
needScheduleTasksQueue.addLast(routineLoadTaskInfo);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
index eeae843aae6..2d007d97815 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
@@ -18,10 +18,12 @@
package org.apache.doris.mtmv;
import org.apache.doris.catalog.Env;
+import org.apache.doris.common.UserException;
import org.apache.doris.common.util.PropertyAnalyzer;
import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.resource.computegroup.ComputeGroupBindingUtil;
import com.google.common.collect.Sets;
import org.apache.commons.lang3.StringUtils;
@@ -36,6 +38,7 @@ public class MTMVPropertyUtil {
PropertyAnalyzer.ASYNC_MV_QUERY_REWRITE_CONSISTENCY_RELAXED_TABLES,
PropertyAnalyzer.PROPERTIES_REFRESH_PARTITION_NUM,
PropertyAnalyzer.PROPERTIES_WORKLOAD_GROUP,
+ PropertyAnalyzer.PROPERTIES_COMPUTE_GROUP,
PropertyAnalyzer.PROPERTIES_PARTITION_SYNC_LIMIT,
PropertyAnalyzer.PROPERTIES_PARTITION_TIME_UNIT,
PropertyAnalyzer.PROPERTIES_PARTITION_DATE_FORMAT,
@@ -60,6 +63,9 @@ public class MTMVPropertyUtil {
case PropertyAnalyzer.PROPERTIES_WORKLOAD_GROUP:
analyzeWorkloadGroup(value);
break;
+ case PropertyAnalyzer.PROPERTIES_COMPUTE_GROUP:
+ analyzeComputeGroup(value);
+ break;
case PropertyAnalyzer.PROPERTIES_PARTITION_TIME_UNIT:
analyzePartitionTimeUnit(value);
break;
@@ -107,6 +113,17 @@ public class MTMVPropertyUtil {
}
}
+ private static void analyzeComputeGroup(String value) {
+ if (StringUtils.isEmpty(value)) {
+ return;
+ }
+ try {
+
ComputeGroupBindingUtil.validateDeclaredComputeGroup(ConnectContext.get(),
value);
+ } catch (UserException e) {
+ throw new AnalysisException(e.getMessage(), e);
+ }
+ }
+
private static void analyzeWorkloadGroup(String value) {
if (StringUtils.isEmpty(value)) {
return;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
index 367480c5d93..0ddf658ff85 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
@@ -40,6 +40,7 @@ import
org.apache.doris.nereids.trees.plans.commands.load.LoadProperty;
import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.StmtExecutor;
+import org.apache.doris.resource.computegroup.ComputeGroupBindingUtil;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
@@ -74,6 +75,7 @@ public class AlterRoutineLoadCommand extends AlterCommand {
.add(CreateRoutineLoadInfo.STRICT_MODE)
.add(CreateRoutineLoadInfo.TIMEZONE)
.add(CreateRoutineLoadInfo.WORKLOAD_GROUP)
+ .add(CreateRoutineLoadInfo.COMPUTE_GROUP)
.add(JsonFileFormatProperties.PROP_JSON_PATHS)
.add(JsonFileFormatProperties.PROP_STRIP_OUTER_ARRAY)
.add(JsonFileFormatProperties.PROP_NUM_AS_STRING)
@@ -162,7 +164,7 @@ public class AlterRoutineLoadCommand extends AlterCommand {
labelNameInfo.validate(ctx);
FeNameFormat.checkCommonName(NAME_TYPE, labelNameInfo.getLabel());
// check routine load job properties include desired concurrent number
etc.
- checkJobProperties();
+ checkJobProperties(ctx);
// check load properties
RoutineLoadJob job = Env.getCurrentEnv().getRoutineLoadManager()
.getJob(getDbName(), getJobName());
@@ -177,7 +179,7 @@ public class AlterRoutineLoadCommand extends AlterCommand {
}
}
- private void checkJobProperties() throws UserException {
+ private void checkJobProperties(ConnectContext ctx) throws UserException {
Optional<String> optional = jobProperties.keySet().stream().filter(
entity ->
!CONFIGURABLE_JOB_PROPERTIES_SET.contains(entity)).findFirst();
if (optional.isPresent()) {
@@ -287,6 +289,16 @@ public class AlterRoutineLoadCommand extends AlterCommand {
analyzedJobProperties.put(CreateRoutineLoadInfo.UNIQUE_KEY_UPDATE_MODE,
modeStr.toUpperCase());
}
+ if (jobProperties.containsKey(CreateRoutineLoadInfo.COMPUTE_GROUP)) {
+ String computeGroup =
jobProperties.get(CreateRoutineLoadInfo.COMPUTE_GROUP);
+ if (!StringUtil.isEmpty(computeGroup)) {
+ // Unlike workload group, the compute group can be fully
validated right here, so do
+ // it now instead of letting a bad name pause the whole job on
the next task.
+ ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx,
computeGroup);
+ analyzedJobProperties.put(CreateRoutineLoadInfo.COMPUTE_GROUP,
computeGroup);
+ }
+ }
+
if (jobProperties.containsKey(CreateRoutineLoadInfo.WORKLOAD_GROUP)) {
String workloadGroup =
jobProperties.get(CreateRoutineLoadInfo.WORKLOAD_GROUP);
if (!StringUtil.isEmpty(workloadGroup)) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
index f67ac2fd965..e231ede470e 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
@@ -55,6 +55,7 @@ import
org.apache.doris.nereids.trees.plans.commands.load.LoadSequenceClause;
import org.apache.doris.nereids.trees.plans.commands.load.LoadWhereClause;
import org.apache.doris.nereids.util.PlanUtils;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.resource.computegroup.ComputeGroupBindingUtil;
import org.apache.doris.resource.workloadgroup.WorkloadGroup;
import org.apache.doris.thrift.TPartialUpdateNewRowPolicy;
import org.apache.doris.thrift.TUniqueKeyUpdateMode;
@@ -93,6 +94,7 @@ public class CreateRoutineLoadInfo {
public static final String PARTIAL_UPDATE_NEW_KEY_POLICY =
"partial_update_new_key_behavior";
public static final String UNIQUE_KEY_UPDATE_MODE =
"unique_key_update_mode";
public static final String WORKLOAD_GROUP = "workload_group";
+ public static final String COMPUTE_GROUP = "compute_group";
public static final String ENDPOINT_REGEX =
"[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]";
public static final String SEND_BATCH_PARALLELISM =
"send_batch_parallelism";
public static final String LOAD_TO_SINGLE_TABLET = "load_to_single_tablet";
@@ -129,6 +131,7 @@ public class CreateRoutineLoadInfo {
.add(PARTIAL_UPDATE_NEW_KEY_POLICY)
.add(UNIQUE_KEY_UPDATE_MODE)
.add(WORKLOAD_GROUP)
+ .add(COMPUTE_GROUP)
.add(FileFormatProperties.PROP_FORMAT)
.add(JsonFileFormatProperties.PROP_JSON_PATHS)
.add(JsonFileFormatProperties.PROP_STRIP_OUTER_ARRAY)
@@ -168,6 +171,16 @@ public class CreateRoutineLoadInfo {
private String workloadGroupName;
+ private String computeGroupName;
+
+ // Set only by the metadata load path (RoutineLoadJob#gsonPostProcess),
which re-parses the
+ // stored statement to rebuild the RoutineLoadDesc rather than to admit a
new job. Resource
+ // existence must not be re-checked there: a compute group can be dropped,
renamed or scaled to
+ // zero backends while a job exists, and an exception during metadata load
is turned into
+ // JobState.CANCELLED, which is final and cannot be undone by RESUME. Such
a job has to be
+ // paused by the per task check instead, which is recoverable.
+ private boolean isReplay = false;
+
/**
* support partial columns load(Only Unique Key Columns)
*/
@@ -382,6 +395,19 @@ public class CreateRoutineLoadInfo {
return workloadGroupName;
}
+ public String getComputeGroupName() {
+ return computeGroupName;
+ }
+
+ /**
+ * Marks this info object as rebuilt from persisted metadata instead of
parsed from a user
+ * statement, so that {@link #validate(ConnectContext)} skips checks
against resources that may
+ * legitimately have disappeared since the job was created.
+ */
+ public void setReplay(boolean isReplay) {
+ this.isReplay = isReplay;
+ }
+
/**
* analyze create table info
*/
@@ -609,13 +635,30 @@ public class CreateRoutineLoadInfo {
RoutineLoadJob.DEFAULT_LOAD_TO_SINGLE_TABLET,
LOAD_TO_SINGLE_TABLET + " should be a boolean");
+ String inputComputeGroupStr = jobProperties.get(COMPUTE_GROUP);
+ if (!StringUtils.isEmpty(inputComputeGroupStr)) {
+ // The name is always adopted, because the workload group check
below resolves in its
+ // namespace, but it is only validated when a user is actually
declaring it. On the
+ // metadata load path the declared group may have been dropped
long ago, and failing
+ // there would cancel the job permanently instead of pausing it -
see isReplay.
+ if (!isReplay) {
+
ComputeGroupBindingUtil.validateDeclaredComputeGroup(ConnectContext.get(),
inputComputeGroupStr);
+ }
+ this.computeGroupName = inputComputeGroupStr;
+ }
+
String inputWorkloadGroupStr = jobProperties.get(WORKLOAD_GROUP);
if (!StringUtils.isEmpty(inputWorkloadGroupStr)) {
ConnectContext tmpCtx = new ConnectContext();
tmpCtx.setCurrentUserIdentity(ConnectContext.get().getCurrentUserIdentity());
tmpCtx.getSessionVariable().setWorkloadGroup(inputWorkloadGroupStr);
if (Config.isCloudMode()) {
- tmpCtx.setCloudCluster(ConnectContext.get().getCloudCluster());
+ // A workload group lives in the namespace of a compute group:
the same name under a
+ // different compute group is a different group. So this
existence check must be done
+ // against the compute group the job will actually run in, not
the session's one.
+
tmpCtx.setCloudCluster(StringUtils.isEmpty(this.computeGroupName)
+ ? ConnectContext.get().getCloudCluster()
+ : this.computeGroupName);
}
List<WorkloadGroup> wgList =
Env.getCurrentEnv().getWorkloadGroupMgr()
.getWorkloadGroup(tmpCtx);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
index d05f8189161..6cec50d4ee5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
@@ -1552,6 +1552,11 @@ public class ConnectContext {
}
public void setCloudCluster(String cluster) {
+ // A compute group only exists in cloud mode. Swallowing the call here
instead of making
+ // every caller wrap it in `if (Config.isCloudMode())` keeps that
check in one place.
+ if (!Config.isCloudMode()) {
+ return;
+ }
this.getSessionVariable().setCloudCluster(cluster);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/resource/computegroup/ComputeGroupBindingUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/resource/computegroup/ComputeGroupBindingUtil.java
new file mode 100644
index 00000000000..c9f8044a183
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/resource/computegroup/ComputeGroupBindingUtil.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.resource.computegroup;
+
+import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.cloud.system.CloudSystemInfoService;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.InternalErrorCode;
+import org.apache.doris.common.UserException;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.qe.ConnectContext;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Validation for the {@code compute_group} property that can be declared on
background jobs
+ * (routine load / async materialized view).
+ *
+ * <p>This is a transitional binding: it adds the ability to <b>declare</b> a
compute group, and
+ * re-checks that declaration before every task, but does not change how the
group is resolved for
+ * jobs that declare none. The property name and its value space are
intentionally identical to the
+ * final {@code (owner, compute_group, workload_group)} design, so that
metadata written by this
+ * version can be read as an explicit "pin" by later versions without any
conversion.
+ *
+ * <p>Two values are rejected on purpose:
+ * <ul>
+ * <li>Any value in non-cloud mode - non-cloud support is not part of this
transitional change,
+ * so no non-cloud metadata will ever carry this key.</li>
+ * <li>{@code DEFAULT} (case insensitive) - it is reserved by the final
design to mean
+ * "follow the owner's default group at runtime". Allowing a job to pin
a group literally
+ * named {@code DEFAULT} would silently change its behavior after
upgrading.</li>
+ * </ul>
+ */
+public class ComputeGroupBindingUtil {
+
+ /**
+ * Reserved value in the final binding design: "not pinned, follow the
owner's default group".
+ * Rejected here so that no job can pin a group literally named {@code
DEFAULT}.
+ */
+ public static final String RESERVED_DEFAULT = "DEFAULT";
+
+ public static final String ERR_NON_CLOUD =
+ "Property 'compute_group' is only supported in cloud mode for
now.";
+
+ private ComputeGroupBindingUtil() {
+ }
+
+ /**
+ * Validates a user declared compute group name.
+ *
+ * <p>An empty value means "not declared" and is treated as a no-op by the
caller, which must
+ * not write the key into the job's property map at all.
+ *
+ * @param ctx the context of the user executing CREATE / ALTER; privileges
are checked against
+ * this user, matching how {@code workload_group} is validated
today
+ * @param computeGroup the declared name
+ */
+ public static void validateDeclaredComputeGroup(ConnectContext ctx, String
computeGroup) throws UserException {
+ if (StringUtils.isEmpty(computeGroup)) {
+ return;
+ }
+
+ if (!Config.isCloudMode()) {
+ throw new UserException(ERR_NON_CLOUD);
+ }
+
+ if (RESERVED_DEFAULT.equalsIgnoreCase(computeGroup)) {
+ throw new UserException("'" + RESERVED_DEFAULT + "' is a reserved
value for property 'compute_group'"
+ + " and can not be used as a compute group name here.");
+ }
+
+ if (ctx == null) {
+ throw new UserException("Can not validate property 'compute_group'
without a connect context.");
+ }
+
+ // Same two checks, and the same order, as `USE @<compute group>`.
+ if
(!Env.getCurrentEnv().getAccessManager().checkCloudPriv(ctx.getCurrentUserIdentity(),
+ computeGroup, PrivPredicate.USAGE, ResourceTypeEnum.CLUSTER)) {
+ throw new UserException("USAGE denied to user '" +
ctx.getQualifiedUser()
+ + "' for compute group '" + computeGroup + "'");
+ }
+
+ if (!((CloudSystemInfoService)
Env.getCurrentSystemInfo()).getCloudClusterNames().contains(computeGroup)) {
+ throw new UserException("Compute group '" + computeGroup + "' not
found.");
+ }
+ }
+
+ /**
+ * Re-checks the compute group a job declared, before each of its tasks
runs.
+ *
+ * <p>Creation-time validation alone is not enough: the group can be
dropped and the owner's
+ * privileges can be revoked while the job keeps running, and without this
check the task would
+ * silently keep using a group its owner is no longer entitled to, or fail
much later with an
+ * unrelated message such as "no available BE found".
+ *
+ * <p>Everything is checked against {@code owner}, the identity the task
actually runs as, not
+ * against whoever created or last altered the job.
+ *
+ * <p>The workload group is deliberately out of scope here. Both callers
resolve it a little
+ * later through {@code WorkloadGroupMgr#getWorkloadGroup(ConnectContext)}
- routine load in
+ * {@code KafkaTaskInfo#createRoutineLoadTask}, an MV refresh in the
coordinator - and that
+ * already runs the same USAGE check against the same owner and the same
existence check in the
+ * same compute group namespace.
+ *
+ * @param owner the identity the task runs as
+ * @param computeGroup the compute group declared on the job; empty means
the job declared none
+ * and there is nothing to re-check
+ */
+ public static void checkComputeGroupBeforeTask(UserIdentity owner, String
computeGroup)
+ throws UserException {
+ if (owner == null) {
+ // Jobs created before the owner was persisted; nothing to check
them against.
+ return;
+ }
+
+ if (!Config.isCloudMode() || StringUtils.isEmpty(computeGroup)) {
+ return;
+ }
+
+ // Deliberately not ComputeGroupMgr.getComputeGroupByName() for the
existence check: when
+ // the group is missing that builds a hint message from the
thread-local ConnectContext,
+ // and the callers here are background threads that do not have one.
+ //
+ // A missing group is left on the default INTERNAL_ERR, which
RoutineLoadTaskScheduler
+ // reports as a retryable pause: the name can come back on its own,
because a compute group
+ // that is merely scaled to zero backends is removed from the cluster
map and re-added when
+ // it scales up again. Auto resume then picks the job up without an
operator.
+ if (!((CloudSystemInfoService)
Env.getCurrentSystemInfo()).getCloudClusterNames()
+ .contains(computeGroup)) {
+ throw new UserException("Compute group '" + computeGroup + "' not
found.");
+ }
+
+ // A revoked privilege is the opposite: somebody decided this owner
may no longer use this
+ // group, and nothing will undo that by itself. CANNOT_RESUME_ERR
keeps ScheduleRule from
+ // auto resuming the job, which would otherwise pause and resume it
every few minutes for
+ // as long as the grant is missing.
+ if (!Env.getCurrentEnv().getAccessManager().checkCloudPriv(owner,
computeGroup,
+ PrivPredicate.USAGE, ResourceTypeEnum.CLUSTER)) {
+ throw new UserException(InternalErrorCode.CANNOT_RESUME_ERR,
+ "USAGE denied to user '" + owner.getQualifiedUser()
+ + "' for compute group '" + computeGroup + "'");
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadComputeGroupTest.java
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadComputeGroupTest.java
new file mode 100644
index 00000000000..fd4fd9dc958
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadComputeGroupTest.java
@@ -0,0 +1,499 @@
+// 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.load.routineload;
+
+import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.cloud.load.CloudRoutineLoadManager;
+import org.apache.doris.cloud.system.CloudSystemInfoService;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.InternalErrorCode;
+import org.apache.doris.common.LoadException;
+import org.apache.doris.common.UserException;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.load.loadv2.LoadTask;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import
org.apache.doris.nereids.trees.plans.commands.info.CreateRoutineLoadInfo;
+import org.apache.doris.nereids.trees.plans.commands.info.LabelNameInfo;
+import org.apache.doris.nereids.trees.plans.commands.load.LoadProperty;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.OriginStatement;
+import org.apache.doris.system.Backend;
+import org.apache.doris.system.SystemInfoService;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Collectors;
+
+/**
+ * The transitional {@code compute_group} declaration on routine load jobs.
+ *
+ * <p>The declaration lives in {@code jobProperties} under the key {@code
compute_group}. It is only
+ * written when the user actually declared one, so an absent key keeps the old
behaviour of using the
+ * cluster snapshotted from the creating session, and later versions can tell
a pinned group apart
+ * from an inherited one just by looking at whether the key is present.
+ */
+public class RoutineLoadComputeGroupTest {
+
+ private static final String SESSION_SNAPSHOT_CG = "cg_from_session";
+ private static final String DECLARED_CG = "cg_declared";
+
+ private String originalDeployMode;
+ private String originalCloudUniqueId;
+ private SystemInfoService originalSystemInfo;
+ private AccessControllerManager originalAccessManager;
+
+ @Before
+ public void setUp() {
+ originalDeployMode = Config.deploy_mode;
+ originalCloudUniqueId = Config.cloud_unique_id;
+ originalSystemInfo = Env.getCurrentSystemInfo();
+ originalAccessManager = Env.getCurrentEnv().getAccessManager();
+ }
+
+ @After
+ public void tearDown() {
+ Config.deploy_mode = originalDeployMode;
+ Config.cloud_unique_id = originalCloudUniqueId;
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
originalSystemInfo);
+ Deencapsulation.setField(Env.getCurrentEnv(), "accessManager",
originalAccessManager);
+ ConnectContext.remove();
+ }
+
+ /**
+ * Puts the FE into cloud mode with a known set of existing compute
groups, and a session that
+ * holds (or does not hold) USAGE on whatever it asks for.
+ */
+ private void enterCloudMode(boolean hasPriv, List<String>
existingClusters) {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+
+ AccessControllerManager accessManager =
Mockito.mock(AccessControllerManager.class);
+
Mockito.when(accessManager.checkCloudPriv(Mockito.any(UserIdentity.class),
Mockito.anyString(),
+ Mockito.any(PrivPredicate.class),
Mockito.any(ResourceTypeEnum.class))).thenReturn(hasPriv);
+ Deencapsulation.setField(Env.getCurrentEnv(), "accessManager",
accessManager);
+
+ Map<String, List<Backend>> clusterToBackends = Maps.newHashMap();
+ for (String cluster : existingClusters) {
+ clusterToBackends.put(cluster,
Lists.newArrayList(createBackend(10000L + clusterToBackends.size())));
+ }
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
+ new ClusterAwareCloudSystemInfoService(clusterToBackends));
+
+ ConnectContext ctx = new ConnectContext();
+ ctx.setCurrentUserIdentity(UserIdentity.ADMIN);
+ ctx.setThreadLocalInfo();
+ }
+
+ private KafkaRoutineLoadJob newJob(String snapshotCluster, String
declaredComputeGroup) {
+ KafkaRoutineLoadJob job = new KafkaRoutineLoadJob();
+ // serialization walks this field, give it something non-null
+ job.setOrigStmt(new OriginStatement("CREATE ROUTINE LOAD test ON tbl",
0));
+ Deencapsulation.setField(job, "cloudCluster", snapshotCluster);
+ if (declaredComputeGroup != null) {
+ Map<String, String> jobProperties = Maps.newHashMap();
+ jobProperties.put(RoutineLoadJob.COMPUTE_GROUP,
declaredComputeGroup);
+ Deencapsulation.setField(job, "jobProperties", jobProperties);
+ }
+ return job;
+ }
+
+ @Test
+ public void testDeclaredComputeGroupWinsOverSessionSnapshot() {
+ KafkaRoutineLoadJob job = newJob(SESSION_SNAPSHOT_CG, DECLARED_CG);
+ Assert.assertEquals(DECLARED_CG, job.getDeclaredComputeGroup());
+ Assert.assertEquals(DECLARED_CG, job.getCloudCluster());
+ }
+
+ // No declaration must leave the existing behaviour completely untouched.
+ @Test
+ public void testFallsBackToSessionSnapshotWhenNotDeclared() {
+ KafkaRoutineLoadJob job = newJob(SESSION_SNAPSHOT_CG, null);
+ Assert.assertNull(job.getDeclaredComputeGroup());
+ Assert.assertEquals(SESSION_SNAPSHOT_CG, job.getCloudCluster());
+ }
+
+ // An empty declared value is treated as "not declared", same as the
workload group property.
+ @Test
+ public void testEmptyDeclarationFallsBackToSnapshot() {
+ KafkaRoutineLoadJob job = newJob(SESSION_SNAPSHOT_CG, "");
+ Assert.assertEquals(SESSION_SNAPSHOT_CG, job.getCloudCluster());
+ }
+
+ // SHOW ROUTINE LOAD must report the cluster the job actually runs in.
+ @Test
+ public void testClusterInfoShowsEffectiveComputeGroup() {
+ Assert.assertEquals(DECLARED_CG, newJob(SESSION_SNAPSHOT_CG,
DECLARED_CG).getClusterInfo());
+ Assert.assertEquals(SESSION_SNAPSHOT_CG, newJob(SESSION_SNAPSHOT_CG,
null).getClusterInfo());
+ Assert.assertEquals("", newJob(null, null).getClusterInfo());
+ }
+
+ // Metadata compatibility: the declaration is carried in the already
existing jobProperties map,
+ // so it survives a serialization round trip without any new persisted
field.
+ @Test
+ public void testMetadataRoundTripKeepsDeclaration() {
+ KafkaRoutineLoadJob job = newJob(SESSION_SNAPSHOT_CG, DECLARED_CG);
+ String json = GsonUtils.GSON.toJson(job);
+ Assert.assertTrue(json, json.contains(RoutineLoadJob.COMPUTE_GROUP));
+
+ KafkaRoutineLoadJob restored = GsonUtils.GSON.fromJson(json,
KafkaRoutineLoadJob.class);
+ Assert.assertEquals(DECLARED_CG, restored.getDeclaredComputeGroup());
+ Assert.assertEquals(DECLARED_CG, restored.getCloudCluster());
+ }
+
+ // Downgrade safety: metadata written without the key must still load and
behave as before.
+ @Test
+ public void testMetadataRoundTripWithoutDeclaration() {
+ KafkaRoutineLoadJob job = newJob(SESSION_SNAPSHOT_CG, null);
+ String json = GsonUtils.GSON.toJson(job);
+ Assert.assertFalse(json, json.contains(RoutineLoadJob.COMPUTE_GROUP));
+
+ KafkaRoutineLoadJob restored = GsonUtils.GSON.fromJson(json,
KafkaRoutineLoadJob.class);
+ Assert.assertNull(restored.getDeclaredComputeGroup());
+ Assert.assertEquals(SESSION_SNAPSHOT_CG, restored.getCloudCluster());
+ }
+
+ /**
+ * Isolation: two jobs pinned to different compute groups must be
scheduled onto disjoint sets of
+ * backends, so neither can consume the other's resources. The backend set
is resolved per job
+ * and on every task allocation, not once at create time.
+ */
+ @Test
+ public void testJobsWithDifferentComputeGroupsGetDisjointBackends() throws
LoadException {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+
+ Backend beInA1 = createBackend(10001L);
+ Backend beInA2 = createBackend(10002L);
+ Backend beInB = createBackend(10003L);
+ Map<String, List<Backend>> clusterToBackends = Maps.newHashMap();
+ clusterToBackends.put("cg_a", Lists.newArrayList(beInA1, beInA2));
+ clusterToBackends.put("cg_b", Lists.newArrayList(beInB));
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
+ new ClusterAwareCloudSystemInfoService(clusterToBackends));
+
+ KafkaRoutineLoadJob jobA = newJob(SESSION_SNAPSHOT_CG, "cg_a");
+ KafkaRoutineLoadJob jobB = newJob(SESSION_SNAPSHOT_CG, "cg_b");
+ Map<Long, RoutineLoadJob> jobs = Maps.newHashMap();
+ jobs.put(1L, jobA);
+ jobs.put(2L, jobB);
+ TestCloudRoutineLoadManager manager = new
TestCloudRoutineLoadManager(jobs);
+
+ List<Long> backendsForA = manager.getAvailableBackendIdsForTest(1L);
+ List<Long> backendsForB = manager.getAvailableBackendIdsForTest(2L);
+
+ Assert.assertEquals(Lists.newArrayList(beInA1.getId(),
beInA2.getId()), backendsForA);
+ Assert.assertEquals(Lists.newArrayList(beInB.getId()), backendsForB);
+ Assert.assertTrue("jobs pinned to different compute groups must not
share backends",
+ backendsForA.stream().noneMatch(backendsForB::contains));
+ }
+
+ /**
+ * Isolation is driven by the declaration, not by the creating session:
both jobs were created in
+ * the same session (same snapshot cluster) yet land on different backends.
+ */
+ @Test
+ public void testDeclarationOverridesIdenticalCreatingSession() throws
LoadException {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+
+ Backend beInA = createBackend(10001L);
+ Backend beInSession = createBackend(10009L);
+ Map<String, List<Backend>> clusterToBackends = Maps.newHashMap();
+ clusterToBackends.put("cg_a", Lists.newArrayList(beInA));
+ clusterToBackends.put(SESSION_SNAPSHOT_CG,
Lists.newArrayList(beInSession));
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
+ new ClusterAwareCloudSystemInfoService(clusterToBackends));
+
+ Map<Long, RoutineLoadJob> jobs = Maps.newHashMap();
+ jobs.put(1L, newJob(SESSION_SNAPSHOT_CG, "cg_a"));
+ jobs.put(2L, newJob(SESSION_SNAPSHOT_CG, null));
+ TestCloudRoutineLoadManager manager = new
TestCloudRoutineLoadManager(jobs);
+
+ Assert.assertEquals(Lists.newArrayList(beInA.getId()),
manager.getAvailableBackendIdsForTest(1L));
+ Assert.assertEquals(Lists.newArrayList(beInSession.getId()),
manager.getAvailableBackendIdsForTest(2L));
+ }
+
+ // Changing the declaration takes effect on the next task allocation, no
restart required.
+ @Test
+ public void testAlteringDeclarationMovesJobToAnotherComputeGroup() throws
LoadException {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+
+ Backend beInA = createBackend(10001L);
+ Backend beInB = createBackend(10002L);
+ Map<String, List<Backend>> clusterToBackends = Maps.newHashMap();
+ clusterToBackends.put("cg_a", Lists.newArrayList(beInA));
+ clusterToBackends.put("cg_b", Lists.newArrayList(beInB));
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
+ new ClusterAwareCloudSystemInfoService(clusterToBackends));
+
+ KafkaRoutineLoadJob job = newJob(SESSION_SNAPSHOT_CG, "cg_a");
+ Map<Long, RoutineLoadJob> jobs = Maps.newHashMap();
+ jobs.put(1L, job);
+ TestCloudRoutineLoadManager manager = new
TestCloudRoutineLoadManager(jobs);
+ Assert.assertEquals(Lists.newArrayList(beInA.getId()),
manager.getAvailableBackendIdsForTest(1L));
+
+ // what ALTER ROUTINE LOAD ... PROPERTIES("compute_group" = "cg_b")
ends up doing
+ Map<String, String> jobProperties = Deencapsulation.getField(job,
"jobProperties");
+ jobProperties.put(RoutineLoadJob.COMPUTE_GROUP, "cg_b");
+
+ Assert.assertEquals(Lists.newArrayList(beInB.getId()),
manager.getAvailableBackendIdsForTest(1L));
+ }
+
+ /**
+ * Metadata load must not re-check the declared compute group.
+ *
+ * <p>{@link RoutineLoadJob#gsonPostProcess()} re-parses the stored CREATE
statement on every FE
+ * metadata load - a restart, and every checkpoint - only to rebuild the
RoutineLoadDesc, and it
+ * turns any failure into {@code JobState.CANCELLED}, which is final and
cannot be undone by
+ * RESUME. A compute group can be dropped, renamed, or scaled to zero
backends (which
+ * CloudSystemInfoService treats as dropped) while a job exists, so
validating it there would
+ * kill the job permanently instead of letting the per task check pause it
recoverably.
+ */
+ @Test
+ public void testMetadataLoadDoesNotValidateDeclaredComputeGroup() throws
UserException {
+ enterCloudMode(true, Lists.newArrayList("cg_live"));
+
+ CreateRoutineLoadInfo info = newCreateInfo("cg_dropped");
+ info.setReplay(true);
+
+ info.checkJobProperties();
+
+ // Only the resource check is skipped: the declaration itself is still
adopted, so the job
+ // keeps running in the group it was pinned to once the group comes
back.
+ Assert.assertEquals("cg_dropped", info.getComputeGroupName());
+ }
+
+ // Negative control for the test above: on the real CREATE path the same
value must still be
+ // rejected, otherwise the skip would have disabled validation everywhere.
+ @Test
+ public void testCreateStillRejectsComputeGroupThatDoesNotExist() {
+ enterCloudMode(true, Lists.newArrayList("cg_live"));
+
+ CreateRoutineLoadInfo info = newCreateInfo("cg_dropped");
+
+ UserException e = Assert.assertThrows(UserException.class,
info::checkJobProperties);
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("Compute group 'cg_dropped' not
found."));
+ }
+
+ /**
+ * The per task re-check has to run before the task takes any resource.
+ *
+ * <p>Backend allocation resolves the backends through the very compute
group being checked, so
+ * a missing group makes it return an empty list and pause the job with a
generic
+ * "no available BE found for job ... please check the BE status and
user's cluster or tags".
+ * If the re-check ran after allocation it could never fire for a dropped
group, and the
+ * operator would be sent to look at BE health for what is really a
compute group that is gone.
+ * Running it first also means no transaction has been begun yet when it
fails.
+ */
+ @Test
+ public void testDroppedComputeGroupFailsTaskBeforeBackendAllocation() {
+ enterCloudMode(true, Lists.newArrayList("cg_live"));
+
+ RecordingKafkaRoutineLoadJob job = new RecordingKafkaRoutineLoadJob();
+ Deencapsulation.setField(job, "state",
RoutineLoadJob.JobState.RUNNING);
+ Deencapsulation.setField(job, "userIdentity", UserIdentity.ADMIN);
+ Map<String, String> jobProperties = Maps.newHashMap();
+ jobProperties.put(RoutineLoadJob.COMPUTE_GROUP, "cg_dropped");
+ Deencapsulation.setField(job, "jobProperties", jobProperties);
+
+ Map<Long, RoutineLoadJob> jobs = Maps.newHashMap();
+ jobs.put(1L, job);
+ RoutineLoadTaskScheduler scheduler =
+ new RoutineLoadTaskScheduler(new
TestCloudRoutineLoadManager(jobs));
+
+ ConcurrentMap<Integer, Long> partitionIdToOffset =
Maps.newConcurrentMap();
+ partitionIdToOffset.put(1, 100L);
+ KafkaTaskInfo taskInfo = new AlwaysReadyKafkaTaskInfo(new UUID(1, 1),
1L, partitionIdToOffset);
+
+ try {
+ Deencapsulation.invoke(scheduler, "scheduleOneTask", taskInfo);
+ // Without the check in front, scheduling reaches
allocateTaskToBe, which finds no
+ // backend for the dropped group, pauses the job with "no
available BE found" and
+ // returns normally - so reaching this line is itself the
regression.
+ Assert.fail("scheduling a task of a job pinned to a dropped
compute group must fail,"
+ + " last pause reason: " + job.pauseMsg);
+ } catch (Exception expected) {
+ // scheduleOneTask pauses the job and rethrows
+ }
+
+ Assert.assertEquals(RoutineLoadJob.JobState.PAUSED, job.newState);
+ Assert.assertNotNull("the job must be paused with a reason",
job.pauseMsg);
+ Assert.assertTrue(job.pauseMsg, job.pauseMsg.contains("Compute group
'cg_dropped' not found."));
+ Assert.assertFalse("the operator must not be sent to look at BE status
for a dropped group",
+ job.pauseMsg.contains("no available BE found"));
+
+ // A group can come back by itself - one that is only scaled to zero
backends leaves the
+ // cluster map and re-enters it when it scales up - so this pause has
to stay retryable.
+ Assert.assertNotEquals(InternalErrorCode.CANNOT_RESUME_ERR,
job.pauseCode);
+ Assert.assertTrue("a dropped group must still auto resume once it is
back",
+ ScheduleRule.isNeedAutoSchedule(job));
+ }
+
+ /**
+ * A revoked privilege is not transient, so the job must stop instead of
flapping.
+ *
+ * <p>Pausing with the default INTERNAL_ERR would let {@link ScheduleRule}
auto resume the job
+ * within at most MAX_BACK_OFF_TIME_SEC, whereupon the next task fails the
same check and pauses
+ * it again - for as long as the grant is missing, at two edit log entries
per cycle.
+ */
+ @Test
+ public void testRevokedUsagePausesTaskWithoutAutoResume() {
+ // the group exists, so the check gets past existence and fails on the
privilege
+ enterCloudMode(false, Lists.newArrayList("cg_pinned"));
+
+ RecordingKafkaRoutineLoadJob job = new RecordingKafkaRoutineLoadJob();
+ Deencapsulation.setField(job, "state",
RoutineLoadJob.JobState.RUNNING);
+ Deencapsulation.setField(job, "userIdentity", UserIdentity.ADMIN);
+ Map<String, String> jobProperties = Maps.newHashMap();
+ jobProperties.put(RoutineLoadJob.COMPUTE_GROUP, "cg_pinned");
+ Deencapsulation.setField(job, "jobProperties", jobProperties);
+
+ Map<Long, RoutineLoadJob> jobs = Maps.newHashMap();
+ jobs.put(1L, job);
+ RoutineLoadTaskScheduler scheduler =
+ new RoutineLoadTaskScheduler(new
TestCloudRoutineLoadManager(jobs));
+
+ ConcurrentMap<Integer, Long> partitionIdToOffset =
Maps.newConcurrentMap();
+ partitionIdToOffset.put(1, 100L);
+ KafkaTaskInfo taskInfo = new AlwaysReadyKafkaTaskInfo(new UUID(1, 1),
1L, partitionIdToOffset);
+
+ try {
+ Deencapsulation.invoke(scheduler, "scheduleOneTask", taskInfo);
+ Assert.fail("a task whose owner lost USAGE on the pinned group
must fail");
+ } catch (Exception expected) {
+ // scheduleOneTask pauses the job and rethrows
+ }
+
+ Assert.assertEquals(RoutineLoadJob.JobState.PAUSED, job.newState);
+ Assert.assertNotNull("the job must be paused with a reason",
job.pauseMsg);
+ Assert.assertTrue(job.pauseMsg, job.pauseMsg.contains("USAGE denied"));
+ Assert.assertEquals(InternalErrorCode.CANNOT_RESUME_ERR,
job.pauseCode);
+ Assert.assertFalse("a revoked privilege must not be auto resumed",
+ ScheduleRule.isNeedAutoSchedule(job));
+ }
+
+ private CreateRoutineLoadInfo newCreateInfo(String declaredComputeGroup) {
+ Map<String, String> jobProperties = Maps.newHashMap();
+ jobProperties.put(CreateRoutineLoadInfo.COMPUTE_GROUP,
declaredComputeGroup);
+ Map<String, String> dataSourceProperties = Maps.newHashMap();
+ dataSourceProperties.put("kafka_broker_list", "127.0.0.1:9092");
+ dataSourceProperties.put("kafka_topic", "test_topic");
+ Map<String, LoadProperty> loadPropertyMap = Maps.newHashMap();
+ return new CreateRoutineLoadInfo(new LabelNameInfo("test_db",
"test_job"), "test_tbl",
+ loadPropertyMap, jobProperties, "kafka", dataSourceProperties,
+ LoadTask.MergeType.APPEND, "");
+ }
+
+ private Backend createBackend(long id) {
+ Backend backend = new Backend(id, "127.0.0." + id, 9050);
+ backend.setAlive(true);
+ return backend;
+ }
+
+ /**
+ * KafkaTaskInfo asks the real RoutineLoadManager, and then Kafka, whether
there is more data to
+ * consume. Neither exists here, so answer yes and let scheduling proceed
to the part under
+ * test: without the compute group check in front, it must reach backend
allocation.
+ */
+ private static class AlwaysReadyKafkaTaskInfo extends KafkaTaskInfo {
+ private AlwaysReadyKafkaTaskInfo(UUID id, long jobId, Map<Integer,
Long> partitionIdToOffset) {
+ super(id, jobId, 20000, partitionIdToOffset, false, -1, false);
+ }
+
+ @Override
+ boolean hasMoreDataToConsume() {
+ return true;
+ }
+ }
+
+ /**
+ * Captures the state transition instead of writing an edit log, and
mirrors it onto the real
+ * fields so that ScheduleRule can be asked what it would do with the
resulting pause.
+ */
+ private static class RecordingKafkaRoutineLoadJob extends
KafkaRoutineLoadJob {
+ private JobState newState;
+ private String pauseMsg;
+ private InternalErrorCode pauseCode;
+
+ @Override
+ public void updateState(JobState jobState, ErrorReason reason, boolean
isReplay) {
+ this.newState = jobState;
+ this.pauseMsg = reason == null ? null : reason.getMsg();
+ this.pauseCode = reason == null ? null : reason.getCode();
+ this.state = jobState;
+ this.pauseReason = reason;
+ }
+ }
+
+ private static class ClusterAwareCloudSystemInfoService extends
CloudSystemInfoService {
+ private final Map<String, List<Backend>> clusterToBackends;
+
+ private ClusterAwareCloudSystemInfoService(Map<String, List<Backend>>
clusterToBackends) {
+ this.clusterToBackends = clusterToBackends;
+ }
+
+ @Override
+ public List<Backend> getBackendsByClusterName(final String
clusterName) {
+ return clusterToBackends.getOrDefault(clusterName,
Lists.newArrayList());
+ }
+
+ @Override
+ public List<String> getCloudClusterNames() {
+ return
clusterToBackends.keySet().stream().collect(Collectors.toList());
+ }
+ }
+
+ private static class TestCloudRoutineLoadManager extends
CloudRoutineLoadManager {
+ private final Map<Long, RoutineLoadJob> jobs;
+
+ private TestCloudRoutineLoadManager(Map<Long, RoutineLoadJob> jobs) {
+ this.jobs = jobs;
+ }
+
+ @Override
+ public RoutineLoadJob getJob(long jobId) {
+ return jobs.get(jobId);
+ }
+
+ // the real one looks the task up in idToRoutineLoadJob, which this
stub never fills
+ @Override
+ public boolean checkTaskInJob(RoutineLoadTaskInfo task) {
+ return jobs.containsKey(task.getJobId());
+ }
+
+ private List<Long> getAvailableBackendIdsForTest(long jobId) throws
LoadException {
+ return super.getAvailableBackendIds(jobId);
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVComputeGroupTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVComputeGroupTest.java
new file mode 100644
index 00000000000..bc9c60b7739
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVComputeGroupTest.java
@@ -0,0 +1,181 @@
+// 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.mtmv;
+
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.common.util.PropertyAnalyzer;
+import org.apache.doris.job.extensions.mtmv.MTMVTask;
+import org.apache.doris.job.extensions.mtmv.MTMVTask.MTMVTaskTriggerMode;
+import org.apache.doris.job.extensions.mtmv.MTMVTaskContext;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Map;
+
+/**
+ * The transitional {@code compute_group} declaration on async materialized
views.
+ *
+ * <p>A declared compute group pins every refresh - automatic and manual alike
- to that group.
+ * Only when nothing is declared does a manual REFRESH keep borrowing the
triggering session's group,
+ * which is the behaviour every existing MV keeps.
+ */
+public class MTMVComputeGroupTest {
+
+ private static final String DECLARED_CG = "cg_declared";
+ private static final String SESSION_CG = "cg_from_session";
+
+ private String originalDeployMode;
+ private String originalCloudUniqueId;
+
+ @Before
+ public void setUp() {
+ originalDeployMode = Config.deploy_mode;
+ originalCloudUniqueId = Config.cloud_unique_id;
+ }
+
+ @After
+ public void tearDown() {
+ Config.deploy_mode = originalDeployMode;
+ Config.cloud_unique_id = originalCloudUniqueId;
+ ConnectContext.remove();
+ }
+
+ private MTMV newMTMV(String declaredComputeGroup) {
+ MTMV mtmv = new MTMV();
+ Map<String, String> mvProperties = Maps.newHashMap();
+ if (declaredComputeGroup != null) {
+ mvProperties.put(PropertyAnalyzer.PROPERTIES_COMPUTE_GROUP,
declaredComputeGroup);
+ }
+ mtmv.setMvProperties(mvProperties);
+ return mtmv;
+ }
+
+ private MTMVTask newTask(MTMV mtmv, MTMVTaskTriggerMode triggerMode,
String taskContextComputeGroup) {
+ MTMVTaskContext taskContext = new MTMVTaskContext(
+ triggerMode, Lists.newArrayList(), false,
taskContextComputeGroup);
+ return new MTMVTask(mtmv, null, taskContext);
+ }
+
+ private String resolveComputeGroup(MTMVTask task) {
+ ConnectContext ctx = new ConnectContext();
+ Deencapsulation.invoke(task, "setComputeGroup", ctx);
+ return ctx.getSessionVariable().getCloudCluster();
+ }
+
+ @Test
+ public void testGetComputeGroupFromProperty() {
+ Assert.assertEquals(DECLARED_CG,
newMTMV(DECLARED_CG).getComputeGroup().orElse(null));
+ Assert.assertFalse(newMTMV(null).getComputeGroup().isPresent());
+ Assert.assertFalse(newMTMV("").getComputeGroup().isPresent());
+ }
+
+ @Test
+ public void testAlterComputeGroupProperty() {
+ MTMV mtmv = newMTMV(DECLARED_CG);
+ Map<String, String> changed = Maps.newHashMap();
+ changed.put(PropertyAnalyzer.PROPERTIES_COMPUTE_GROUP,
"cg_after_alter");
+ mtmv.alterMvProperties(changed);
+ Assert.assertEquals("cg_after_alter",
mtmv.getComputeGroup().orElse(null));
+ }
+
+ // Automatic refresh has no compute group on the task context; the
declaration is what pins it.
+ @Test
+ public void testAutoRefreshUsesDeclaredComputeGroup() {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+ MTMVTask task = newTask(newMTMV(DECLARED_CG),
MTMVTaskTriggerMode.SYSTEM, null);
+ Assert.assertEquals(DECLARED_CG, resolveComputeGroup(task));
+ }
+
+ // The one deliberate behaviour change: a declared compute group also wins
for a manual REFRESH,
+ // so the same MV never refreshes in two different places depending on who
triggered it.
+ @Test
+ public void testManualRefreshPrefersDeclaredOverSessionComputeGroup() {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+ MTMVTask task = newTask(newMTMV(DECLARED_CG),
MTMVTaskTriggerMode.MANUAL, SESSION_CG);
+ Assert.assertEquals(DECLARED_CG, resolveComputeGroup(task));
+ }
+
+ // Existing MVs declare nothing, so a manual REFRESH must keep borrowing
the session's group.
+ @Test
+ public void testManualRefreshFallsBackToSessionComputeGroup() {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+ MTMVTask task = newTask(newMTMV(null), MTMVTaskTriggerMode.MANUAL,
SESSION_CG);
+ Assert.assertEquals(SESSION_CG, resolveComputeGroup(task));
+ }
+
+ // Nothing declared and nothing on the task context leaves the context
untouched, which keeps the
+ // existing implicit resolution (admin's default compute group, or one
picked by policy).
+ @Test
+ public void testAutoRefreshWithoutDeclarationLeavesContextUntouched() {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+ MTMVTask task = newTask(newMTMV(null), MTMVTaskTriggerMode.SYSTEM,
null);
+ // untouched, i.e. left at the session variable's default so the
implicit resolution applies
+ Assert.assertTrue(Strings.isNullOrEmpty(resolveComputeGroup(task)));
+ }
+
+ // Non-cloud is out of scope; the declaration can not be created there,
and even if metadata
+ // somehow carried one it must not touch the context.
+ @Test
+ public void testNoopInNonCloudMode() {
+ Config.deploy_mode = "";
+ Config.cloud_unique_id = "";
+ MTMVTask task = newTask(newMTMV(DECLARED_CG),
MTMVTaskTriggerMode.MANUAL, SESSION_CG);
+ Assert.assertTrue(Strings.isNullOrEmpty(resolveComputeGroup(task)));
+ }
+
+ /**
+ * Isolation: two MVs declaring different compute groups resolve to
different clusters, which is
+ * what makes their refreshes pick disjoint backend sets downstream.
+ */
+ @Test
+ public void testMvsWithDifferentComputeGroupsResolveIndependently() {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+ MTMVTask taskA = newTask(newMTMV("cg_a"), MTMVTaskTriggerMode.SYSTEM,
null);
+ MTMVTask taskB = newTask(newMTMV("cg_b"), MTMVTaskTriggerMode.SYSTEM,
null);
+
+ String resolvedA = resolveComputeGroup(taskA);
+ String resolvedB = resolveComputeGroup(taskB);
+ Assert.assertEquals("cg_a", resolvedA);
+ Assert.assertEquals("cg_b", resolvedB);
+ Assert.assertNotEquals(resolvedA, resolvedB);
+ }
+
+ // A task carries no task context when it is read back from meta, so
resolving must fall back to
+ // the declaration rather than fail, and stay a no-op when there is no
declaration either.
+ @Test
+ public void testTaskWithoutTaskContextUsesDeclaredComputeGroup() {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+ Assert.assertEquals(DECLARED_CG, resolveComputeGroup(new
MTMVTask(newMTMV(DECLARED_CG), null, null)));
+ Assert.assertTrue(Strings.isNullOrEmpty(resolveComputeGroup(new
MTMVTask(newMTMV(null), null, null))));
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/resource/computegroup/ComputeGroupBindingUtilTest.java
b/fe/fe-core/src/test/java/org/apache/doris/resource/computegroup/ComputeGroupBindingUtilTest.java
new file mode 100644
index 00000000000..d0c03b371d9
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/resource/computegroup/ComputeGroupBindingUtilTest.java
@@ -0,0 +1,238 @@
+// 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.resource.computegroup;
+
+import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.cloud.system.CloudSystemInfoService;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.InternalErrorCode;
+import org.apache.doris.common.UserException;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.system.SystemInfoService;
+
+import com.google.common.collect.Lists;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.List;
+
+/**
+ * Validation of the transitional {@code compute_group} declaration.
+ */
+public class ComputeGroupBindingUtilTest {
+
+ private static final String CG_OK = "cg_ok";
+
+ private String originalDeployMode;
+ private String originalCloudUniqueId;
+ private SystemInfoService originalSystemInfo;
+ private SystemInfoService originalCgMgrSystemInfo;
+ private AccessControllerManager originalAccessManager;
+ private ConnectContext ctx;
+
+ @Before
+ public void setUp() {
+ originalDeployMode = Config.deploy_mode;
+ originalCloudUniqueId = Config.cloud_unique_id;
+ originalSystemInfo = Env.getCurrentSystemInfo();
+ originalCgMgrSystemInfo = Deencapsulation.getField(
+ Env.getCurrentEnv().getComputeGroupMgr(), "systemInfoService");
+ originalAccessManager = Env.getCurrentEnv().getAccessManager();
+ ctx = new ConnectContext();
+ ctx.setCurrentUserIdentity(UserIdentity.ADMIN);
+ }
+
+ @After
+ public void tearDown() {
+ Config.deploy_mode = originalDeployMode;
+ Config.cloud_unique_id = originalCloudUniqueId;
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo",
originalSystemInfo);
+ Deencapsulation.setField(Env.getCurrentEnv().getComputeGroupMgr(),
"systemInfoService",
+ originalCgMgrSystemInfo);
+ Deencapsulation.setField(Env.getCurrentEnv(), "accessManager",
originalAccessManager);
+ ConnectContext.remove();
+ }
+
+ private void enterCloudMode(boolean hasPriv, List<String>
existingClusters) {
+ Config.deploy_mode = "cloud";
+ Config.cloud_unique_id = "";
+ AccessControllerManager accessManager =
Mockito.mock(AccessControllerManager.class);
+
Mockito.when(accessManager.checkCloudPriv(Mockito.any(UserIdentity.class),
Mockito.anyString(),
+ Mockito.any(PrivPredicate.class),
Mockito.any(ResourceTypeEnum.class))).thenReturn(hasPriv);
+ Deencapsulation.setField(Env.getCurrentEnv(), "accessManager",
accessManager);
+ TestCloudSystemInfoService svc = new
TestCloudSystemInfoService(existingClusters);
+ Deencapsulation.setField(Env.getCurrentEnv(), "systemInfo", svc);
+ // ComputeGroupMgr captured its own reference at construction, so
replacing only Env's would
+ // leave getComputeGroupByName() casting the real non-cloud service.
+ Deencapsulation.setField(Env.getCurrentEnv().getComputeGroupMgr(),
"systemInfoService", svc);
+ }
+
+ // An empty declaration means "not declared" and must never fail, not even
in non-cloud mode,
+ // otherwise every existing job would break.
+ @Test
+ public void testEmptyDeclarationIsNoOp() throws UserException {
+ Config.deploy_mode = "";
+ Config.cloud_unique_id = "";
+ ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, null);
+ ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, "");
+ }
+
+ // Non-cloud is out of scope for this transitional change: declaring a
compute group must be
+ // rejected so that no non-cloud metadata ever carries the key.
+ @Test
+ public void testRejectedInNonCloudMode() {
+ Config.deploy_mode = "";
+ Config.cloud_unique_id = "";
+ UserException e = Assert.assertThrows(UserException.class,
+ () ->
ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, CG_OK));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("only
supported in cloud mode"));
+ }
+
+ // DEFAULT is reserved by the final binding design ("follow the owner's
default group").
+ // Pinning a group literally named DEFAULT would silently change behaviour
after upgrading.
+ @Test
+ public void testRejectReservedDefaultValue() {
+ enterCloudMode(true, Lists.newArrayList("DEFAULT", "default", CG_OK));
+ for (String reserved : new String[] {"DEFAULT", "default", "Default"})
{
+ UserException e = Assert.assertThrows(UserException.class,
+ () ->
ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, reserved));
+ Assert.assertTrue(e.getMessage(),
e.getMessage().contains("reserved value"));
+ }
+ }
+
+ @Test
+ public void testRejectWhenNoUsagePrivilege() {
+ enterCloudMode(false, Lists.newArrayList(CG_OK));
+ UserException e = Assert.assertThrows(UserException.class,
+ () ->
ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, CG_OK));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("USAGE
denied"));
+ }
+
+ @Test
+ public void testRejectWhenComputeGroupDoesNotExist() {
+ enterCloudMode(true, Lists.newArrayList("some_other_cg"));
+ UserException e = Assert.assertThrows(UserException.class,
+ () ->
ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, CG_OK));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("not
found"));
+ }
+
+ @Test
+ public void testAcceptValidDeclaration() throws UserException {
+ enterCloudMode(true, Lists.newArrayList(CG_OK, "cg_other"));
+ ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, CG_OK);
+ }
+
+ // The privilege check must run before the existence check, so that
probing for the existence of
+ // a compute group the user has no access to is not possible.
+ @Test
+ public void testPrivilegeIsCheckedBeforeExistence() {
+ enterCloudMode(false, Lists.newArrayList("some_other_cg"));
+ UserException e = Assert.assertThrows(UserException.class,
+ () ->
ComputeGroupBindingUtil.validateDeclaredComputeGroup(ctx, CG_OK));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("USAGE
denied"));
+ Assert.assertFalse(e.getMessage(), e.getMessage().contains("not
found"));
+ }
+
+ // ---- checkComputeGroupBeforeTask: re-checked before every task, against
the job's owner ----
+
+ // Jobs created before the owner was persisted have nothing to check
against.
+ @Test
+ public void testRuntimeCheckSkippedWhenOwnerUnknown() throws UserException
{
+ enterCloudMode(false, Lists.newArrayList());
+ ComputeGroupBindingUtil.checkComputeGroupBeforeTask(null, "cg_gone");
+ }
+
+ // Nothing is bound, so there is nothing to re-check.
+ @Test
+ public void testRuntimeCheckSkippedWhenNothingBound() throws UserException
{
+ enterCloudMode(false, Lists.newArrayList());
+
ComputeGroupBindingUtil.checkComputeGroupBeforeTask(UserIdentity.ADMIN, null);
+ }
+
+ // The compute group was dropped while the job kept running.
+ //
+ // This runs without a thread-local ConnectContext on purpose: the callers
are background
+ // threads (the routine load scheduler, the MV task runner) that have
none, so the check must
+ // not reach any code path that needs one.
+ @Test
+ public void testRuntimeCheckFailsWhenComputeGroupDropped() {
+ enterCloudMode(true, Lists.newArrayList());
+ ConnectContext.remove();
+ UserException e = Assert.assertThrows(UserException.class,
+ () ->
ComputeGroupBindingUtil.checkComputeGroupBeforeTask(UserIdentity.ADMIN,
"cg_gone"));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("not
found"));
+ // A missing group can come back on its own - one that is only scaled
to zero backends
+ // leaves the cluster map and re-enters it when it scales up - so the
pause this produces
+ // has to stay retryable for RoutineLoadTaskScheduler / ScheduleRule.
+ Assert.assertNotEquals(InternalErrorCode.CANNOT_RESUME_ERR,
e.getErrorCode());
+ }
+
+ // The owner's USAGE on the compute group was revoked while the job kept
running. This is the
+ // case creation-time validation alone cannot catch.
+ @Test
+ public void testRuntimeCheckFailsWhenComputeGroupUsageRevoked() {
+ enterCloudMode(false, Lists.newArrayList(CG_OK));
+ UserException e = Assert.assertThrows(UserException.class,
+ () ->
ComputeGroupBindingUtil.checkComputeGroupBeforeTask(UserIdentity.ADMIN, CG_OK));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("USAGE
denied"));
+ // Unlike a missing group, nothing undoes a REVOKE by itself, so the
pause must not be auto
+ // resumed - otherwise the job flaps every few minutes for as long as
the grant is missing.
+ Assert.assertEquals(InternalErrorCode.CANNOT_RESUME_ERR,
e.getErrorCode());
+ }
+
+ // Non-cloud has no named compute group, so the check is skipped entirely.
+ @Test
+ public void testRuntimeCheckSkipsComputeGroupInNonCloudMode() throws
UserException {
+ Config.deploy_mode = "";
+ Config.cloud_unique_id = "";
+
ComputeGroupBindingUtil.checkComputeGroupBeforeTask(UserIdentity.ADMIN,
"cg_gone");
+ }
+
+ private static class TestCloudSystemInfoService extends
CloudSystemInfoService {
+ private final List<String> clusterNames;
+
+ private TestCloudSystemInfoService(List<String> clusterNames) {
+ this.clusterNames = clusterNames;
+ }
+
+ @Override
+ public List<String> getCloudClusterNames() {
+ return clusterNames;
+ }
+
+ // getComputeGroupByName() resolves through these two, so a known name
has to look
+ // resolvable here or the existence check fires before anything else
can be exercised.
+ @Override
+ public String getPhysicalCluster(String clusterName) {
+ return clusterName;
+ }
+
+ @Override
+ public String getCloudClusterIdByName(String clusterName) {
+ return clusterNames.contains(clusterName) ? clusterName + "_id" :
"";
+ }
+ }
+}
diff --git
a/regression-test/suites/cloud_p0/multi_cluster/test_compute_group_binding_isolation.groovy
b/regression-test/suites/cloud_p0/multi_cluster/test_compute_group_binding_isolation.groovy
new file mode 100644
index 00000000000..e2323b52cf4
--- /dev/null
+++
b/regression-test/suites/cloud_p0/multi_cluster/test_compute_group_binding_isolation.groovy
@@ -0,0 +1,133 @@
+// 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.
+
+import org.apache.doris.regression.suite.ClusterOptions
+
+// Isolation across compute groups, end to end.
+//
+// Two async materialized views declaring different compute groups must
refresh in their own group
+// and nowhere else, no matter which group the triggering session is on. The
MV refresh path needs
+// no external data source, which makes it the cheapest way to prove the
binding end to end.
+suite('test_compute_group_binding_isolation', 'multi_cluster,docker') {
+ def options = new ClusterOptions()
+ options.feConfigs += [
+ 'cloud_cluster_check_interval_second=1',
+ ]
+ options.cloudMode = true
+
+ docker(options) {
+ def secondGroup = "cg_isolation_b"
+
+ // Resolve the session's own compute group before the second group
exists. A session with no
+ // explicit group picks the first group with an alive backend, and
that list is sorted by
+ // name, so once cg_isolation_b is up it would win over the default
group and the two names
+ // below would collide.
+ def groups = sql_return_maparray """show clusters"""
+ logger.info("clusters: ${groups}")
+ def firstGroup = groups.stream()
+ .filter(cg -> cg.is_current ==
"TRUE").findFirst().orElse(null)?.cluster
+ assertNotNull(firstGroup)
+ assertNotEquals(firstGroup, secondGroup)
+
+ cluster.addBackend(1, secondGroup)
+ // Keep this session on its original group now that a second one
exists.
+ sql """use @${firstGroup}"""
+
+ def tableName = "test_cg_isolation_tbl"
+ def mvOnFirst = "test_cg_isolation_mv_a"
+ def mvOnSecond = "test_cg_isolation_mv_b"
+
+ sql """DROP TABLE IF EXISTS ${tableName} FORCE"""
+ sql """
+ CREATE TABLE ${tableName} (
+ `k1` INT NULL,
+ `k2` INT NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`k1`)
+ DISTRIBUTED BY HASH(`k1`) BUCKETS 2
+ PROPERTIES ('replication_num' = '1');
+ """
+ sql """INSERT INTO ${tableName} VALUES (1, 1), (2, 2), (3, 3);"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mvOnFirst}
+ BUILD DEFERRED REFRESH AUTO ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 2
+ PROPERTIES ('replication_num' = '1', 'compute_group' =
'${firstGroup}')
+ AS SELECT k1, k2 FROM ${tableName};
+ """
+ sql """
+ CREATE MATERIALIZED VIEW ${mvOnSecond}
+ BUILD DEFERRED REFRESH AUTO ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 2
+ PROPERTIES ('replication_num' = '1', 'compute_group' =
'${secondGroup}')
+ AS SELECT k1, k2 FROM ${tableName};
+ """
+
+ sql """REFRESH MATERIALIZED VIEW ${mvOnFirst} AUTO;"""
+ sql """REFRESH MATERIALIZED VIEW ${mvOnSecond} AUTO;"""
+ waitingMTMVTaskFinishedByMvName(mvOnFirst)
+ waitingMTMVTaskFinishedByMvName(mvOnSecond)
+
+ def computeGroupOfLastTask = { mvName ->
+ def tasks = sql_return_maparray """
+ select * from tasks("type"="mv") where MvName = '${mvName}'
order by CreateTime desc
+ """
+ assertTrue(tasks.size() > 0)
+ logger.info("tasks of ${mvName}: ${tasks}")
+ return tasks.get(0).ComputeGroup
+ }
+
+ // Each MV refreshed in its own compute group, and the two are
different.
+ assertEquals(firstGroup, computeGroupOfLastTask(mvOnFirst))
+ assertEquals(secondGroup, computeGroupOfLastTask(mvOnSecond))
+
+ // Both produced correct data, i.e. pinning to a non-session group did
not break the refresh.
+ def rowsA = sql "SELECT COUNT(*) FROM ${mvOnFirst}"
+ def rowsB = sql "SELECT COUNT(*) FROM ${mvOnSecond}"
+ assertEquals(3, rowsA.get(0).get(0))
+ assertEquals(3, rowsB.get(0).get(0))
+
+ // The declaration wins over the triggering session: refreshing from
inside the second group
+ // must still send the first MV to the first group.
+ // COMPLETE, not AUTO: the base table has not changed since the first
refresh, so AUTO would
+ // resolve to NOT_REFRESH and never report a compute group to assert
on.
+ sql """use @${secondGroup}"""
+ sql """REFRESH MATERIALIZED VIEW ${mvOnFirst} COMPLETE;"""
+ waitingMTMVTaskFinishedByMvName(mvOnFirst)
+ assertEquals(firstGroup, computeGroupOfLastTask(mvOnFirst))
+
+ // And an MV that declares nothing keeps the old behaviour of
borrowing the session's group.
+ def mvUndeclared = "test_cg_isolation_mv_none"
+ sql """
+ CREATE MATERIALIZED VIEW ${mvUndeclared}
+ BUILD DEFERRED REFRESH AUTO ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 2
+ PROPERTIES ('replication_num' = '1')
+ AS SELECT k1, k2 FROM ${tableName};
+ """
+ sql """REFRESH MATERIALIZED VIEW ${mvUndeclared} AUTO;"""
+ waitingMTMVTaskFinishedByMvName(mvUndeclared)
+ assertEquals(secondGroup, computeGroupOfLastTask(mvUndeclared))
+
+ sql """use @${firstGroup}"""
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvOnFirst};"""
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvOnSecond};"""
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvUndeclared};"""
+ sql """DROP TABLE IF EXISTS ${tableName} FORCE"""
+ }
+}
diff --git
a/regression-test/suites/load_p0/routine_load/test_routine_load_compute_group.groovy
b/regression-test/suites/load_p0/routine_load/test_routine_load_compute_group.groovy
new file mode 100644
index 00000000000..4f3ba4f0032
--- /dev/null
+++
b/regression-test/suites/load_p0/routine_load/test_routine_load_compute_group.groovy
@@ -0,0 +1,286 @@
+// 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.
+
+import org.apache.kafka.clients.producer.KafkaProducer
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.apache.kafka.clients.producer.ProducerConfig
+
+// Declaring `compute_group` on a routine load job.
+//
+// The declaration is stored in the job's property map under the key
`compute_group`, which is the
+// same key and value space the final (owner, compute_group, workload_group)
design uses, so the
+// metadata is read back as an explicit pin by later versions without
conversion.
+//
+// The property-level checks run before the Kafka data source is touched, so
the negative cases do
+// not need a broker; only the "job really runs there" cases do.
+suite("test_routine_load_compute_group", "p0") {
+ String tableName = "test_routine_load_compute_group_tbl"
+ String jobName = "test_routine_load_compute_group_job"
+
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `k1` INT NULL,
+ `k2` STRING NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`k1`)
+ DISTRIBUTED BY HASH(`k1`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+
+ // Returns the statement instead of running it: inside a `test { }` block
the `sql` method must
+ // be the action's own, so the SQL has to be handed to it there rather
than executed here.
+ def createJobSql = { String computeGroup ->
+ return """
+ CREATE ROUTINE LOAD ${jobName} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES ("compute_group" = "${computeGroup}")
+ FROM KAFKA ("kafka_broker_list" = "127.0.0.1:19092", "kafka_topic"
= "unused_topic");
+ """.toString()
+ }
+
+ if (!isCloudMode()) {
+ // Non-cloud is out of scope for this transitional change, so the key
can never appear in
+ // non-cloud metadata and upgrading such a cluster has nothing to
convert. The cloud-mode
+ // check runs first, so even the reserved DEFAULT is refused with this
message.
+ for (String value : ["any_group", "DEFAULT", "default"]) {
+ test {
+ sql createJobSql(value)
+ exception "only supported in cloud mode"
+ }
+ }
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+ return
+ }
+
+ // ---------------- cloud mode ----------------
+
+ // DEFAULT is reserved by the final design ("follow the owner's default
group"). A job pinned to
+ // a group literally named DEFAULT would be silently reinterpreted after
an upgrade.
+ for (String reserved : ["DEFAULT", "default", "Default"]) {
+ test {
+ sql createJobSql(reserved)
+ exception "reserved value"
+ }
+ }
+
+ test {
+ sql createJobSql("cg_that_does_not_exist")
+ exception "not found"
+ }
+
+ def currentComputeGroup = sql_return_maparray("show clusters")
+ .stream().filter(cg -> cg.is_current ==
"TRUE").findFirst().orElse(null)
+ assertNotNull(currentComputeGroup)
+ def cgName = currentComputeGroup.cluster
+ logger.info("current compute group: ${cgName}")
+
+ String enabled = context.config.otherConfigs.get("enableKafkaTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("kafka test not enabled, skipping the running-job part")
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+ return
+ }
+
+ String kafka_port = context.config.otherConfigs.get("kafka_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ def kafka_broker = "${externalEnvIp}:${kafka_port}"
+ def topic = "test_routine_load_compute_group"
+
+ def props = new Properties()
+ props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,
"${kafka_broker}".toString())
+ props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
+ "org.apache.kafka.common.serialization.StringSerializer")
+ props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
+ "org.apache.kafka.common.serialization.StringSerializer")
+ props.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, "10000")
+ def producer = new KafkaProducer<>(props)
+ for (int i = 0; i < 5; i++) {
+ producer.send(new ProducerRecord<>(topic, "${i},row${i}".toString()))
+ }
+ producer.close()
+
+ try {
+ sql """
+ CREATE ROUTINE LOAD ${jobName} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES (
+ "max_batch_interval" = "5",
+ "max_batch_rows" = "300000",
+ "max_batch_size" = "209715200",
+ "compute_group" = "${cgName}"
+ )
+ FROM KAFKA (
+ "kafka_broker_list" = "${kafka_broker}",
+ "kafka_topic" = "${topic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ // SHOW must report the compute group the job actually runs in, and
the declaration must be
+ // visible in the job properties so operators can inventory pinned
jobs before an upgrade.
+ def show = sql_return_maparray("SHOW ROUTINE LOAD FOR
${jobName}").get(0)
+ logger.info("show routine load: ${show}")
+ assertEquals(cgName, show.ComputeGroup)
+ assertTrue(show.JobProperties.contains("compute_group"))
+ assertTrue(show.JobProperties.contains(cgName))
+
+ // The job runs, i.e. pinning did not break the load path.
+ def count = 0
+ while (count < 60) {
+ def state = sql_return_maparray("SHOW ROUTINE LOAD FOR
${jobName}").get(0).State
+ def rows = sql "SELECT COUNT(*) FROM ${tableName}"
+ logger.info("state=${state}, rows=${rows}")
+ assertNotEquals("PAUSED", state)
+ if (rows.get(0).get(0) >= 5) {
+ break
+ }
+ sleep(2000)
+ count++
+ }
+ assertEquals(5, sql("SELECT COUNT(*) FROM ${tableName}").get(0).get(0))
+
+ // ALTER validates the new value immediately instead of letting a bad
name pause the job on
+ // the next task. ALTER ROUTINE LOAD only accepts PAUSED jobs.
+ sql "PAUSE ROUTINE LOAD FOR ${jobName}"
+ test {
+ sql """ALTER ROUTINE LOAD FOR ${jobName}
PROPERTIES("compute_group" = "DEFAULT");"""
+ exception "reserved value"
+ }
+ test {
+ sql """ALTER ROUTINE LOAD FOR ${jobName}
PROPERTIES("compute_group" = "cg_nope");"""
+ exception "not found"
+ }
+
+ sql """ALTER ROUTINE LOAD FOR ${jobName} PROPERTIES("compute_group" =
"${cgName}");"""
+ def showAfterAlter = sql_return_maparray("SHOW ROUTINE LOAD FOR
${jobName}").get(0)
+ assertEquals(cgName, showAfterAlter.ComputeGroup)
+
+ sql "RESUME ROUTINE LOAD FOR ${jobName}"
+ } finally {
+ try {
+ sql "STOP ROUTINE LOAD FOR ${jobName}"
+ } catch (Exception e) {
+ logger.info("stop routine load failed: ${e.getMessage()}")
+ }
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+ }
+
+ // The binding is re-checked before every task, not only at CREATE time:
revoking the job
+ // owner's USAGE on the declared compute group must pause the job with
that reason.
+ //
+ // The check is deliberately placed before the task takes any resource -
before backend
+ // allocation and before the load transaction is begun - so that a group
which has been dropped
+ // outright cannot first surface as "no available BE found". That ordering
is what
+ //
RoutineLoadComputeGroupTest#testDroppedComputeGroupFailsTaskBeforeBackendAllocation
pins
+ // down; here we cover the privilege half end to end, which needs a real
running job.
+ String revokeUser = "test_rl_cg_revoke_user"
+ String revokePwd = "Test_12345"
+ String revokeJob = "test_rl_cg_revoke_job"
+ String revokeTable = "test_rl_cg_revoke_tbl"
+
+ sql """DROP TABLE IF EXISTS ${revokeTable}"""
+ sql """
+ CREATE TABLE ${revokeTable} (
+ `k1` INT NULL,
+ `k2` STRING NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`k1`)
+ DISTRIBUTED BY HASH(`k1`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+ sql """DROP USER IF EXISTS ${revokeUser}"""
+ sql """CREATE USER '${revokeUser}' IDENTIFIED BY '${revokePwd}'"""
+ sql """GRANT LOAD_PRIV, SELECT_PRIV ON *.*.* TO ${revokeUser}"""
+ sql """GRANT USAGE_PRIV ON COMPUTE GROUP `${cgName}` TO ${revokeUser}"""
+
+ try {
+ // Created by the user, so RoutineLoadJob#userIdentity - the identity
the per task check
+ // runs against - is that user rather than the admin running this
suite.
+ connect(revokeUser, "${revokePwd}", context.config.jdbcUrl) {
+ sql """use ${context.dbName}"""
+ sql """
+ CREATE ROUTINE LOAD ${revokeJob} ON ${revokeTable}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES (
+ "max_batch_interval" = "5",
+ "max_batch_rows" = "300000",
+ "max_batch_size" = "209715200",
+ "compute_group" = "${cgName}"
+ )
+ FROM KAFKA (
+ "kafka_broker_list" = "${kafka_broker}",
+ "kafka_topic" = "${topic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+ }
+
+ def runningBeforeRevoke = false
+ for (int i = 0; i < 60; i++) {
+ def state = sql_return_maparray("SHOW ROUTINE LOAD FOR
${revokeJob}").get(0).State
+ if (state == "RUNNING") {
+ runningBeforeRevoke = true
+ break
+ }
+ sleep(1000)
+ }
+ assertTrue(runningBeforeRevoke, "the job must be running before its
privilege is revoked")
+
+ sql """REVOKE USAGE_PRIV ON COMPUTE GROUP `${cgName}` FROM
${revokeUser}"""
+
+ // The check runs ahead of the "is there more data to consume" probe,
so the job is refused
+ // on the next scheduling round whether or not the topic has new
records.
+ def pausedForComputeGroup = false
+ def sawBeAvailabilityReason = false
+ def lastSeen = ""
+ for (int i = 0; i < 90; i++) {
+ def row = sql_return_maparray("SHOW ROUTINE LOAD FOR
${revokeJob}").get(0)
+ def reason = row.ReasonOfStateChanged == null ? "" :
row.ReasonOfStateChanged
+ lastSeen = "state=${row.State}, reason=${reason}"
+ if (reason.contains("no available BE found")) {
+ sawBeAvailabilityReason = true
+ }
+ if (reason.contains("USAGE denied") && reason.contains(cgName)) {
+ pausedForComputeGroup = true
+ break
+ }
+ sleep(1000)
+ }
+ assertTrue(pausedForComputeGroup,
+ "revoking USAGE must fail the next task with the real reason,
last seen: ${lastSeen}".toString())
+ assertFalse(sawBeAvailabilityReason,
+ "a compute group problem must never be reported as a BE
availability problem")
+
+ // Nothing undoes a REVOKE by itself, so the pause carries
CANNOT_RESUME_ERR and the job
+ // must stay down instead of being auto resumed into the same failure
every few minutes.
+ for (int i = 0; i < 15; i++) {
+ def state = sql_return_maparray("SHOW ROUTINE LOAD FOR
${revokeJob}").get(0).State
+ assertEquals("PAUSED", state,
+ "a job paused by a revoked privilege must not be auto
resumed")
+ sleep(1000)
+ }
+ } finally {
+ try {
+ sql "STOP ROUTINE LOAD FOR ${revokeJob}"
+ } catch (Exception e) {
+ logger.info("stop routine load failed: ${e.getMessage()}")
+ }
+ sql """DROP TABLE IF EXISTS ${revokeTable}"""
+ sql """DROP USER IF EXISTS ${revokeUser}"""
+ }
+}
diff --git a/regression-test/suites/mtmv_p0/test_mtmv_compute_group.groovy
b/regression-test/suites/mtmv_p0/test_mtmv_compute_group.groovy
new file mode 100644
index 00000000000..c671892a125
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/test_mtmv_compute_group.groovy
@@ -0,0 +1,142 @@
+// 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.
+
+// Declaring `compute_group` on an async materialized view.
+//
+// The property is a transitional binding whose name and value space match the
final
+// (owner, compute_group, workload_group) design, so metadata written here is
read as an explicit
+// pin by later versions. Two values are rejected on purpose: anything in
non-cloud mode, and the
+// reserved word DEFAULT. The non-cloud check runs first, so in non-cloud mode
every value - DEFAULT
+// included - is refused with the same "cloud mode" message.
+suite("test_mtmv_compute_group") {
+ String suiteName = "test_mtmv_compute_group"
+ String tableName = "${suiteName}_table"
+ String mvName = "${suiteName}_mv"
+
+ sql """drop materialized view if exists ${mvName};"""
+ sql """drop table if exists `${tableName}`"""
+
+ sql """
+ CREATE TABLE `${tableName}` (
+ `k1` INT NULL,
+ `k2` INT NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`k1`)
+ DISTRIBUTED BY HASH(`k1`) BUCKETS 2
+ PROPERTIES ('replication_num' = '1');
+ """
+ sql """insert into ${tableName} values(1, 1), (2, 2);"""
+
+ // Returns the statement instead of running it: inside a `test { }` block
the `sql` method must
+ // be the action's own, so the SQL has to be handed to it there rather
than executed here.
+ def createMvSql = { String properties ->
+ return """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH AUTO ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 2
+ PROPERTIES (${properties})
+ AS SELECT k1, k2 FROM ${tableName};
+ """.toString()
+ }
+
+ if (!isCloudMode()) {
+ // Non-cloud is out of scope for this transitional change: declaring
the property must be
+ // rejected whatever the value, so that non-cloud metadata never
carries the key and
+ // upgrading such a cluster has nothing to convert.
+ for (String value : ["any_group", "DEFAULT", "default"]) {
+ test {
+ sql createMvSql("'replication_num' = '1', 'compute_group' =
'${value}'")
+ exception "only supported in cloud mode"
+ }
+ }
+
+ // An MV without the property must keep working exactly as before.
+ sql createMvSql("'replication_num' = '1'")
+ sql """REFRESH MATERIALIZED VIEW ${mvName} AUTO;"""
+ waitingMTMVTaskFinishedByMvName(mvName)
+ assertEquals(2, sql("SELECT COUNT(*) FROM ${mvName}").get(0).get(0))
+
+ // ALTER must reject it too, not only CREATE.
+ test {
+ sql """ALTER MATERIALIZED VIEW ${mvName} SET ('compute_group' =
'any_group');"""
+ exception "only supported in cloud mode"
+ }
+
+ sql """drop materialized view if exists ${mvName};"""
+ sql """drop table if exists `${tableName}`"""
+ return
+ }
+
+ // ---------------- cloud mode ----------------
+
+ // DEFAULT is reserved by the final design ("follow the owner's default
group"). Pinning a group
+ // literally named DEFAULT would be silently reinterpreted after an
upgrade.
+ for (String reserved : ["DEFAULT", "default", "Default"]) {
+ test {
+ sql createMvSql("'replication_num' = '1', 'compute_group' =
'${reserved}'")
+ exception "reserved value"
+ }
+ }
+
+ test {
+ sql createMvSql("'replication_num' = '1', 'compute_group' =
'cg_that_does_not_exist'")
+ exception "not found"
+ }
+
+ def currentComputeGroup = sql_return_maparray("show clusters")
+ .stream().filter(cg -> cg.is_current ==
"TRUE").findFirst().orElse(null)
+ assertNotNull(currentComputeGroup)
+ def cgName = currentComputeGroup.cluster
+ logger.info("current compute group: ${cgName}")
+
+ sql createMvSql("'replication_num' = '1', 'compute_group' = '${cgName}'")
+
+ // The declaration is persisted on the MV and visible.
+ def showCreate = sql """show create materialized view ${mvName};"""
+ assertTrue(showCreate.toString().contains("compute_group"))
+ assertTrue(showCreate.toString().contains(cgName))
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} AUTO;"""
+ waitingMTMVTaskFinishedByMvName(mvName)
+ assertEquals(2, sql("SELECT COUNT(*) FROM ${mvName}").get(0).get(0))
+
+ // The refresh really ran in the declared compute group.
+ def tasks = sql_return_maparray """select * from tasks("type"="mv") where
MvName = '${mvName}'"""
+ assertTrue(tasks.size() > 0)
+ logger.info("mv tasks: ${tasks}")
+ assertEquals(cgName, tasks.get(0).ComputeGroup)
+
+ // ALTER keeps the same value space, including the DEFAULT rejection.
+ test {
+ sql """ALTER MATERIALIZED VIEW ${mvName} SET ('compute_group' =
'DEFAULT');"""
+ exception "reserved value"
+ }
+ test {
+ sql """ALTER MATERIALIZED VIEW ${mvName} SET ('compute_group' =
'cg_that_does_not_exist');"""
+ exception "not found"
+ }
+
+ // Re-declaring the same group is a no-op and must keep refreshing there.
+ sql """ALTER MATERIALIZED VIEW ${mvName} SET ('compute_group' =
'${cgName}');"""
+ sql """REFRESH MATERIALIZED VIEW ${mvName} AUTO;"""
+ waitingMTMVTaskFinishedByMvName(mvName)
+ def tasksAfterAlter = sql_return_maparray """select * from
tasks("type"="mv") where MvName = '${mvName}'"""
+ assertEquals(cgName, tasksAfterAlter.get(0).ComputeGroup)
+
+ sql """drop materialized view if exists ${mvName};"""
+ sql """drop table if exists `${tableName}`"""
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]