This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new ab778e7c1 feat(instance): scope topic and group uniqueness to the
nameserver domain (#7353)
ab778e7c1 is described below
commit ab778e7c12855c4d8a49186320d176fd3e8c1170
Author: lizhimins <[email protected]>
AuthorDate: Mon Oct 26 14:59:04 2026 +0800
feat(instance): scope topic and group uniqueness to the nameserver domain
(#7353)
Bind each APACHE instance to a nameserver registry entry at creation and
derive a
NOT NULL ownership domain from it (ns:<nameserver_id>,
cloud:<cloudInstanceId>),
so a topic or group name is unique per (domain, broker cluster) instead of
per
deployment. The ownership guard becomes slot based: same name on another
cluster
of the domain coexists, the owning instance re-creates as an update, and a
foreign
owner is reported with the create rejected.
Rewrite docs/studio-instance-model-spec.md to the domain wording: the spec
still
claimed deployment-wide global uniqueness and a name-only unique key, which
is what
this change replaces.
---
docs/studio-instance-model-spec.md | 98 ++++++---
.../nameserver/NameserverRegistryService.java | 20 ++
.../studio/instance/CreateInstanceDTO.java | 3 +
.../rocketmq/studio/instance/InstanceService.java | 24 +-
.../rocketmq/studio/instance/InstanceVO.java | 2 +
.../instance/MybatisPlusInstanceRepository.java | 5 +
.../studio/instance/ResourceOwnershipGuard.java | 150 ++++++++++---
.../instance/ResourceOwnershipSchemaMigration.java | 241 +++++++++++++++------
.../studio/instance/UpdateInstanceDTO.java | 3 +
.../studio/instance/message/MessageService.java | 2 +-
.../studio/instance/topic/LiteTopicService.java | 2 +-
.../studio/instance/topic/MetadataService.java | 6 +-
.../studio/persistence/entity/RmqGroup.java | 2 +
.../studio/persistence/entity/RmqInstance.java | 2 +
.../studio/persistence/entity/RmqTopic.java | 2 +
.../provider/apache/RocketMQAdminClientImpl.java | 128 +++++++----
.../provider/apache/RocketMQLiteTopicProvider.java | 4 +-
.../provider/apache/RocketMQMessageProvider.java | 8 +-
server/src/main/resources/db/schema.sql | 14 +-
.../studio/instance/InstanceServiceTest.java | 87 +++-----
.../MybatisPlusInstanceRepositoryTest.java | 3 +
.../instance/ResourceOwnershipGuardTest.java | 142 ++++++++----
.../instance/topic/LiteTopicServiceTest.java | 2 +-
.../studio/instance/topic/MetadataServiceTest.java | 32 +--
.../apache/RocketMQAdminClientImplTest.java | 41 +++-
.../apache/RocketMQLiteTopicProviderTest.java | 7 +-
.../apache/RocketMQMessageProviderTest.java | 6 +-
27 files changed, 716 insertions(+), 320 deletions(-)
diff --git a/docs/studio-instance-model-spec.md
b/docs/studio-instance-model-spec.md
index 6cbd50961..031a64dae 100644
--- a/docs/studio-instance-model-spec.md
+++ b/docs/studio-instance-model-spec.md
@@ -1,48 +1,94 @@
-# Studio 实例模型与 Topic/Group 全局唯一规范
+# Studio 实例模型与 Topic/Group 唯一性域规范
-> 状态:**规范已定**。当前代码与本规范不一致(DB 唯一键仍为 `(cluster_id, instance_id, name)`),落地项见
§5,未排期。
+> 状态:**已落地**。资源身份由「名字全局唯一」改为「按唯一性域 + broker 集群」,DB 唯一键为
+> `uk_topic_domain (domain, cluster_id, name)` / `uk_group_domain (domain,
cluster_id, name)`,
+> 存量库由 `ResourceOwnershipSchemaMigration` 在启动时回填并换键(见 §5)。本文取代此前的
+> 「Topic/Group 全局唯一」口径。
>
-> TL;DR (EN): One physical cluster may host multiple Studio instances;
`instanceId` is a Studio-side ownership concept only — open-source RocketMQ has
no namespace/instanceId. Topic and group names are **globally unique within one
Studio deployment**, even across multiple nameservers/clusters. Environments
that need identical resource names (dev/staging/prod) must run **separate
Studio deployments**.
+> TL;DR (EN): One physical cluster may host multiple Studio instances;
`instanceId` is a Studio-side
+> ownership concept only — open-source RocketMQ has no namespace/instanceId. A
topic or group name is
+> unique **per uniqueness domain and broker cluster**, where the domain is the
NameServer registry entry
+> an open-source instance is bound to (`ns:<nameserver_id>`) or the cloud
instance itself
+> (`cloud:<cloudInstanceId>`). Instances bound to different NameServer
clusters may therefore reuse the
+> same names within one Studio deployment, while two instances sharing one
NameServer and one broker
+> cluster still cannot claim the same slot.
## 1. 层级模型
```
-nameserver(接入点,物理) ← 多个 cluster 可共享
+nameserver(接入点,物理;rmq_nameserver 一行) ← 多个 cluster 可共享
└── cluster(物理集群) ← 1 个 cluster 可挂多个 instance
└── instance(Studio 逻辑管理单元,instanceId = rmq_instance.name 全局唯一)
+ └── topic / group(资源身份 = domain + cluster_id + name)
```
- 1 个 cluster 上可以有**多个 instance**(1:N);每个 instance 由唯一 `instanceId` 标识。
- 多个 cluster 可能**共享同一 nameserver**。
-- **instanceId 只是 Studio 侧概念**:开源 RocketMQ 运行时没有 namespace / instanceId——一个
nameserver 之下,topic/group 集合是确定性的、扁平的。
+- **开源(APACHE)实例在创建时绑定一个 nameserver 注册项**(`rmq_instance.nameserver_id`,创建后
+ 不可更改);直连模式的接入地址必须始终指向该集群。云厂商实例的域由 `cloud_instance_id` 给出,
+ 不需要绑定 nameserver。
+- **instanceId 只是 Studio 侧概念**:开源 RocketMQ 运行时没有 namespace / instanceId——一个
+ nameserver 之下,topic/group 集合是确定性的、扁平的。
-## 2. Topic/Group 全局唯一(核心约定)
+## 2. 唯一性域与 slot(核心约定)
-- 一个 Studio 部署内,topic 名与 group 名各自**全局唯一**:数据库唯一键直接加在 `name` 上(替代现行
`(cluster_id, instance_id, name)` 三列 UK)。
-- 一个名字只有一条管理记录:`instance_id` 仅表达**归属实例**(管理视图),`cluster_id`
仅表达物理集群引用——两者都不是资源身份的组成部分。
-- **即使用户管理多套 nameserver / 多个物理集群,名字同样要求全局唯一**(不按 cluster 隔离):
- - 管理考量:检索、审计、告警与 MCP/CLI 工具中资源语义单一,杜绝「同名不同集群」的歧义。
- - 技术考量:共享 nameserver 时,路由按 topic 名注册与查询(`TopicRouteData` 按名聚合、不按 cluster
隔离),跨集群同名 topic 本身就会造成路由混杂——全局唯一从源头消除该隐患。
+- **域(domain)**:`ns:<nameserver_id>`(开源实例)或 `cloud:<cloudInstanceId>`(云实例),落库为
+ `rmq_instance_topic.domain` / `rmq_instance_group.domain`(NOT
NULL)。域表达「这个名字在哪一套
+ 接入点下才可能被路由到」。
+- **slot = (domain, cluster_id, name)**:唯一键直接建在这三列上,**一个 slot 只归属一个实例**。
+ 一个名字在一个域内可以有多个 slot(同域不同 broker cluster 各一条管理记录)。
+- 由此得到三条边界:
+ - **跨域同名互不影响**——日常 / 预发 / 线上各自绑定自己的 nameserver,就能在一个 Studio 内共存。
+ - **同域跨 cluster 同名可共存**——路由本身按 cluster 隔离,各 cluster 上是各自独立的物理资源。
+ - **同域同 cluster 同名 = 同一个物理资源**,归属唯一;他人已占有则拒绝(见 §4)。
+- `instance_id` 仅表达**归属实例**(管理视图),不是资源身份的组成部分。
+- 未绑定 nameserver 的开源实例无法确定域:`ResourceOwnershipGuard.domainOf` 抛 409,提示带
+ nameserver 重新创建该实例(老实例由启动迁移自动补绑,见 §5)。
- **对外参数命名**(MCP tools / REST / CLI 一体适用):实例维度入参一律 **`instanceId`**,禁止以
`cluster` 作为实例参数名——`cluster`/`clusterId` 仅保留物理集群语义(如
`cluster.list`、`TopicVO.clusterId`);`instanceId`
为**正式必填参数、无默认注入设计**,由调用方显式传入(工具 schema 与参数表必须显式列出,不得以「由 context
提供」等理由省略);资源名入参一律 **`topicName` / `groupName`**,单资源操作为精确匹配,list
类工具以其为模糊查询参数(替代泛化的 `search`);CLI/MCP **无 namespace 概念**——工具入/出参一律不暴露
`namespace`(开源链路无 `ns%topic` 物理前缀,namespace 仅为展示字段,从工具中删除;REST/VO 与 CSV 导出不受影响)。
+- `clusterId` 只在「域内多个 broker cluster 都有该名字」时才是必填的消歧参数,其余情况由后端解析
+ (见 §4 第 2 条),不把它变成所有接口的常规必填项。
-## 3. 同名需求 = 部署多套 Studio
+## 3. 同名需求 = 绑不同的 nameserver
-- 用户确需同名 topic/group(典型:**日常 / 预发 / 线上**三套环境复用同一批资源名)时,正确做法是**部署多套独立
Studio**(每环境一套),而不是在一个 Studio 内容忍重名。
-- 一个 Studio = 一个环境的治理域,域内全局唯一天然成立。
+- 用户确需同名 topic/group(典型:**日常 / 预发 / 线上**三套环境复用同一批资源名)时,正确做法是
+ **给每套环境的实例绑定各自的 nameserver 集群**——域不同,名字自然互不冲突。
+- 部署多套 Studio 仍然可行,但不再是同名需求的唯一手段。
+- 一个 Studio = 一个治理面板,面板内可以并列多套环境;真正的冲突只发生在「同域 + 同 broker
+ cluster + 同名」,而那本来就是同一个物理资源。
## 4. 行为约束(由规范导出,REST / MCP / CLI 一体适用)
-1. **create / import**:先按 `name` 查重——名字已存在且归属其他实例 → 拒绝(409,提示归属实例);归属本实例 → 幂等
upsert(create==update 语义不变)。import **不得静默覆盖**物理配置,同名冲突逐条记入 failures。
-2. **update / delete /
消息与位点操作**:仅归属实例可执行;跨实例请求拒绝(409,提示归属实例)。归属转移暂不提供(如确有需求另行设计「转移归属」功能)。
-3. **list / describe**:管理视图——仅展示归属该实例的资源;**物理可见 ≠ 归属**(同一 cluster
新建的实例不会自动看到既有 topic)。既有集群资源经 import 按名进入某实例视图(受第 1 条查重约束)。
-4. **delete**:删除物理资源 + 唯一管理记录;不存在多实例「僵尸记录」问题(一实例删除、另一实例仍显示)——该问题随三列 UK 废除而根除。
+1. **create / import**:目标 cluster 取「本实例在该域内已有的同名注册」,没有则用实例默认 cluster
+ (`createTarget`);**其他 cluster 上他人的同名注册不阻塞创建**。解析出的 slot 归属他实例 →
+ 拒绝(409,提示归属实例);归属本实例 → 幂等 upsert(create==update 语义不变)。import
+ **不得静默覆盖**物理配置,同名冲突逐条记入 failures。
+2. **配置类变更(update / delete / topic 与 group 配置)**:必须有唯一物理目标(`checkSlot`)。
+ `clusterId` 为空时,该名字在域内只有一条注册就用它,有多条则拒绝(409,提示需要指定物理目标);
+ 仅归属实例可执行,跨实例请求拒绝(409,提示归属实例)。归属转移暂不提供(如确有需求另行设计
+ 「转移归属」功能)。
+3. **按名路由的操作(发消息、查消息、重置位点等)**:走域级归属(`checkOwnerInDomain`)——本实例
+ 自己的注册优先命中;域内只有他人注册时拒绝(409)。
+4. **list / describe**:管理视图——仅展示归属该实例的资源;**物理可见 ≠ 归属**(同一 cluster 新建的
+ 实例不会自动看到既有 topic)。既有集群资源经 import 按 slot 进入某实例视图(受第 1 条约束)。
+5. **delete**:删除物理资源 + 该 slot 的管理记录;同域其他 cluster、其他域的同名注册不受影响。
+ 归属一旦提交,不因后续远端操作失败而释放(避免半删状态被他人抢占)。
-## 5. 落地项(待办,未排期)
+## 5. 实现位置
-- `server/src/main/resources/db/schema.sql`:
- - `uk_cluster_instance_topic (cluster_id, instance_id, name)` →
`uk_topic_name (name)`;
- - `uk_cluster_instance_group (cluster_id, instance_id, name)` →
`uk_group_name (name)`;
- - `cluster_id` / `instance_id` 列保留(归属与物理集群引用),维持普通索引(`idx_topic_instance` 等)。
-- `RocketMQAdminClientImpl.createTopic / deleteTopic`(及 group 对应方法):DB
查重与删除条件由 `(cluster_id, instance_id, name)` 改为按 `name`,加归属校验与 409
冲突错误;`MetadataService.importTopics` 随 `createTopic` 生效。
-- **存量数据迁移**:迁移脚本先检测跨实例同名行;存在即停下报告、由人工合并或裁决归属,禁止自动丢弃。
-- 测试与文档:补充归属冲突 / 跨实例拒绝用例。
+-
`server/src/main/resources/db/schema.sql`:`rmq_instance.nameserver_id`(应用层校验存在性,不加外键)、
+ 两张资源表的 `domain` 列、`uk_topic_domain` / `uk_group_domain` 与 `idx_*_domain`。
+- `ResourceOwnershipGuard`:`domainOf`(算域)、`createTarget` / `checkSlot` /
`checkOwnerInDomain` /
+ `slots`(解析 slot 与归属)、`write`(带行锁落归属);云资源尚未全局登记,写操作直接拒绝而不把
+ OpenAPI 可见性当归属。
+- `ResourceOwnershipSchemaMigration`:启动时补列 → 逐实例解析域(nameserver 注册表里没有的接入地址
+ 自动注册一行,云实例缺 `cloud_instance_id` 则拒绝启动)→ 回填资源行 `domain`(归属为空的历史行按
+ `cluster_id` 兜底,仍无法判定则拒绝启动)→ 校验同 slot 无重名(有则拒绝启动、要求人工裁决)→
+ **先建 `uk_*_domain` 再删旧唯一键**,中途失败不丢失既有约束。
+- `InstanceService` / `CreateInstanceDTO` / `UpdateInstanceDTO` / `InstanceVO`
/
+ `MybatisPlusInstanceRepository`:`nameserverId`
创建必填、更新不可改;`NameserverRegistryService`
+ 提供选择器所需的注册项与存在性校验。
+- 调用点:`RocketMQAdminClientImpl`(topic / group
的创建、删除、配置)、`MetadataService`(import)、
+ `LiteTopicService`、`MessageService`、`RocketMQMessageProvider` /
`RocketMQLiteTopicProvider`。
+- 前端:实例页的 nameserver 选择器与列;创建实例 / Topic / Group 三处表单的问号弹窗
+ (`ResourceRuleHelp` + `ResourceOwnershipTopology`)——把域边界、归属拓扑与四条归属规则讲在
+ 「创建动作发生的地方」,不设独立说明页。
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
index 516f6bac0..c964291ae 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameserverRegistryService.java
@@ -126,6 +126,26 @@ public class NameserverRegistryService {
return new BusinessException(409, "NameServer registry name already
exists: " + name);
}
+ public NameserverRegistryVO requireExisting(long id) {
+ RmqNameserver entity = nameserverMapper.selectById(id);
+ if (entity == null) {
+ throw new BusinessException(404, "NameServer registry entry not
found: " + id);
+ }
+ return toVO(entity);
+ }
+
+ public String addressOf(long id) {
+ return requireExisting(id).getNamesrvAddr();
+ }
+
+ public String nameOf(Long id) {
+ if (id == null) {
+ return null;
+ }
+ RmqNameserver entity = nameserverMapper.selectById(id);
+ return entity == null ? null : entity.getName();
+ }
+
public void delete(Long id) {
if (nameserverMapper.selectById(id) == null) {
throw new BusinessException(404, "NameServer registry entry not
found: " + id);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/CreateInstanceDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/CreateInstanceDTO.java
index 8b3497fe9..cdb8d33ed 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/CreateInstanceDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/CreateInstanceDTO.java
@@ -41,6 +41,8 @@ public class CreateInstanceDTO {
private String regionId;
+ private Long nameserverId;
+
public InstanceVO toInstanceVO() {
InstanceVO vo = InstanceVO.builder()
.name(name)
@@ -52,6 +54,7 @@ public class CreateInstanceDTO {
.credentialId(credentialId)
.adminCredentialRef(adminCredentialRef)
.regionId(regionId)
+ .nameserverId(nameserverId)
.build();
return vo;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 462e188d6..6fce8cae2 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -22,6 +22,8 @@ import org.springframework.util.StringUtils;
import
org.apache.rocketmq.studio.provider.credential.CloudCredentialRepository;
import org.apache.rocketmq.studio.provider.credential.CloudCredentialVO;
import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryService;
+import org.apache.rocketmq.studio.cluster.nameserver.NamesrvAddrParser;
import org.apache.rocketmq.studio.cluster.broker.MqClientPool;
import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
@@ -77,6 +79,7 @@ public class InstanceService {
private final CacheManager cacheManager;
private final RegionNames regionNames;
private final ResourceOwnershipGuard ownershipGuard;
+ private final NameserverRegistryService nameserverRegistry;
// @Lazy self-injection: Spring AOP proxies intercept @Transactional calls
only when they
// originate from outside the bean. Calling deleteInstance() directly from
within this class
@@ -510,6 +513,10 @@ public class InstanceService {
if (instance.getType() == InstanceType.CLOUD) {
throw new BusinessException(400, "CLOUD type is reserved for
vendor-managed instances");
}
+ if (instance.getNameserverId() == null || instance.getNameserverId()
<= 0) {
+ throw new BusinessException(400, "nameserverId is required");
+ }
+ nameserverRegistry.requireExisting(instance.getNameserverId());
}
/**
@@ -654,8 +661,23 @@ public class InstanceService {
}
updated.setType(instance.getType());
}
+ if (instance.getNameserverId() != null
+ &&
!instance.getNameserverId().equals(existing.getNameserverId())) {
+ throw new BusinessException(400, "The bound NameServer cluster
cannot be changed after creation");
+ }
if (instance.getEndpoint() != null) {
-
updated.setEndpoint(requireValidEndpoint(instance.getEndpoint()));
+ String normalized =
requireValidEndpoint(instance.getEndpoint());
+ // A DIRECT endpoint is the NameServer address itself, so it
must keep matching the binding;
+ // proxy endpoints address the proxy tier and stay free to
move inside the same domain.
+ if (existing.getType() == InstanceType.DIRECT &&
existing.getNameserverId() != null
+ &&
!NamesrvAddrParser.normalize(nameserverRegistry.addressOf(existing.getNameserverId()))
+
.equals(NamesrvAddrParser.normalize(normalized))) {
+ throw new BusinessException(400,
+ "The endpoint must stay inside the bound
NameServer cluster: " + existing.getName());
+ }
+ // A DIRECT endpoint is stored in the same normalized form as
the registry address it binds to.
+ updated.setEndpoint(existing.getType() == InstanceType.DIRECT
&& existing.getNameserverId() != null
+ ? NamesrvAddrParser.normalize(normalized) :
normalized);
}
}
if (instance.getRemark() != null) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
index 60a14d155..f38f83664 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
@@ -42,6 +42,8 @@ public class InstanceVO extends BaseEntity {
private String adminCredentialRef;
private String regionId;
private String regionName;
+ private Long nameserverId;
+ private String nameserverName;
private int topicCount;
private int consumerGroupCount;
@Builder.Default
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
index 7fcd1a33f..92ad16c60 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepository.java
@@ -27,6 +27,7 @@ import
org.apache.rocketmq.studio.persistence.entity.RmqInstance;
import org.apache.rocketmq.studio.persistence.entity.RmqTopic;
import org.apache.rocketmq.studio.persistence.mapper.RmqGroupMapper;
import org.apache.rocketmq.studio.persistence.mapper.RmqInstanceMapper;
+import org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryService;
import org.apache.rocketmq.studio.persistence.mapper.RmqTopicMapper;
import org.springframework.stereotype.Repository;
import lombok.RequiredArgsConstructor;
@@ -42,6 +43,7 @@ public class MybatisPlusInstanceRepository implements
InstanceRepository {
private final RmqInstanceMapper instanceMapper;
private final RmqTopicMapper topicMapper;
private final RmqGroupMapper groupMapper;
+ private final NameserverRegistryService nameserverRegistry;
@Override
public List<InstanceVO> findAll() {
@@ -176,7 +178,9 @@ public class MybatisPlusInstanceRepository implements
InstanceRepository {
.credentialId(entity.getCredentialId())
.adminCredentialRef(entity.getAdminCredentialRef())
.regionId(entity.getRegionId())
+ .nameserverId(entity.getNameserverId())
.build();
+
vo.setNameserverName(nameserverRegistry.nameOf(entity.getNameserverId()));
vo.setId(entity.getId());
vo.setGmtCreate(entity.getGmtCreate());
vo.setGmtModified(entity.getGmtModified());
@@ -223,6 +227,7 @@ public class MybatisPlusInstanceRepository implements
InstanceRepository {
entity.setCredentialId(vo.getCredentialId());
entity.setAdminCredentialRef(vo.getAdminCredentialRef());
entity.setRegionId(vo.getRegionId());
+ entity.setNameserverId(vo.getNameserverId());
entity.setGmtCreate(vo.getGmtCreate());
entity.setGmtModified(vo.getGmtModified());
return entity;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
index 651d8ab2f..b311253d7 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
@@ -36,11 +36,19 @@ import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
-/** Global ownership with database row locks; a committed claim is never
released by remote operation failures. */
+/**
+ * Slot ownership with database row locks. A slot is (domain, cluster, name):
the domain is the
+ * NameServer cluster an instance is bound to (or the cloud instance itself),
so the same name may
+ * live in different domains and on different broker clusters of one domain,
while one slot belongs
+ * to exactly one instance. A committed claim is never released by remote
operation failures.
+ */
@Component
@DependsOn("resourceOwnershipSchemaMigration")
@RequiredArgsConstructor
public class ResourceOwnershipGuard {
+ private static final String NAMESERVER_DOMAIN_PREFIX = "ns:";
+ private static final String CLOUD_DOMAIN_PREFIX = "cloud:";
+
private final JdbcTemplate jdbc;
private final PlatformTransactionManager transactionManager;
private final InstanceResolver instanceResolver;
@@ -54,7 +62,8 @@ public class ResourceOwnershipGuard {
public record Resource(Kind kind, String name) { }
- public record Ownership(long id, String name, String instanceId, String
clusterId, String topicType) { }
+ public record Ownership(long id, String name, String instanceId, String
clusterId, String topicType,
+ String domain) { }
public InstanceVO requireInstance(String identifier) {
String id = requireText(identifier, "instanceId");
@@ -69,6 +78,27 @@ public class ResourceOwnershipGuard {
return value.trim();
}
+ public static String nameserverDomain(long nameserverId) {
+ return NAMESERVER_DOMAIN_PREFIX + nameserverId;
+ }
+
+ public static String cloudDomain(String cloudInstanceId) {
+ return CLOUD_DOMAIN_PREFIX + requireText(cloudInstanceId,
"cloudInstanceId");
+ }
+
+ /** The uniqueness domain an instance lives in; open-source instances must
be bound to a NameServer. */
+ public String domainOf(InstanceVO instance) {
+ if (instance.getVendor() != null && instance.getVendor() !=
InstanceVendor.APACHE) {
+ return cloudDomain(instance.getCloudInstanceId());
+ }
+ if (instance.getNameserverId() == null || instance.getNameserverId()
<= 0) {
+ throw new BusinessException(409,
+ "Instance is not bound to a NameServer cluster; re-create
it with a NameServer selected: "
+ + instance.getName());
+ }
+ return nameserverDomain(instance.getNameserverId());
+ }
+
/** Derived topics borrow the group ownership only; no regular topic
registration is created. */
public Resource topicResource(String name) {
String topic = requireText(name, "topicName");
@@ -85,8 +115,50 @@ public class ResourceOwnershipGuard {
return new Resource(Kind.TOPIC, topic);
}
- public Ownership check(InstanceVO instance, Resource resource, boolean
required) {
- return checkOwner(instance, resource, read(resource, false), required);
+ /**
+ * Resolves the slot of a configuration mutation. A null cluster accepts
the single registration of
+ * the name inside the domain and rejects an ambiguous one, so the caller
learns the physical target.
+ */
+ public Ownership checkSlot(InstanceVO instance, Resource resource, String
cluster, boolean required) {
+ List<Ownership> rows = read(domainOf(instance), resource, cluster,
false);
+ if (rows.size() > 1) {
+ throw new BusinessException(409, resource.name()
+ + " exists in multiple Broker clusters; a physical target
is required for configuration changes");
+ }
+ return checkOwner(instance, resource, rows.isEmpty() ? null :
rows.getFirst(), required);
+ }
+
+ /**
+ * Domain-level ownership for operations that route by name instead of
mutating a registration
+ * (sending messages, resetting offsets): the caller's own registration
wins, a foreign one rejects.
+ */
+ public Ownership checkOwnerInDomain(InstanceVO instance, Resource
resource, boolean required) {
+ List<Ownership> rows = read(domainOf(instance), resource, null, false);
+ Ownership own = rows.stream()
+ .filter(row -> instance.getName().equals(row.instanceId()))
+ .findFirst()
+ .orElse(null);
+ if (own != null) {
+ return own;
+ }
+ return checkOwner(instance, resource, rows.isEmpty() ? null :
rows.getFirst(), required);
+ }
+
+ /** Every registration of the name inside the instance domain, one per
broker cluster. */
+ public List<Ownership> slots(InstanceVO instance, Resource resource) {
+ return read(domainOf(instance), resource, null, false);
+ }
+
+ /**
+ * Picks the cluster a creation should target: the caller's existing
registration when there is one,
+ * otherwise the instance default. Foreign registrations on other clusters
do not block a creation;
+ * {@link #write} rejects the request only when the resolved slot itself
is owned by someone else.
+ */
+ public Ownership createTarget(InstanceVO instance, Resource resource) {
+ return read(domainOf(instance), resource, null, false).stream()
+ .filter(row -> instance.getName().equals(row.instanceId()))
+ .findFirst()
+ .orElse(null);
}
/** Cloud resources are not registered globally yet; reject writes instead
of treating OpenAPI visibility as ownership. */
@@ -103,10 +175,10 @@ public class ResourceOwnershipGuard {
throw new BusinessException(400, "resourceName is required");
}
if (topic != null) {
- check(instance, topicResource(topic), false);
+ checkSlot(instance, topicResource(topic), null, false);
}
if (group != null) {
- check(instance, new Resource(Kind.GROUP, requireText(group,
"groupName")), false);
+ checkSlot(instance, new Resource(Kind.GROUP, requireText(group,
"groupName")), null, false);
}
throw new BusinessException(501, "Cloud resources are not registered
for global ownership yet; resource writes are not allowed");
}
@@ -114,36 +186,39 @@ public class ResourceOwnershipGuard {
public <T> T write(InstanceVO instance, Resource resource, String cluster,
boolean create,
String topicType, Supplier<T> action) {
requireSupportedProvider(instance);
+ String domain = domainOf(instance);
+ String target = requireText(cluster, "clusterId");
// Commit the claim first; later RPC, DB updates or process crashes
must not let other instances take over.
try {
newTransaction().executeWithoutResult(status -> {
lockInstance(instance);
- Ownership existing = checkOwner(instance, resource,
read(resource, true), !create);
+ Ownership existing = checkOwner(instance, resource,
+ read(domain, resource, target,
true).stream().findFirst().orElse(null), !create);
if (existing == null) {
if (resource.kind() == Kind.TOPIC) {
- jdbc.update("INSERT INTO rmq_instance_topic (name,
instance_id, cluster_id, status, topic_type)"
- + " VALUES (?, ?, ?, 'PENDING', ?)",
resource.name(), instance.getName(), cluster,
- topicType == null ? "NORMAL" : topicType);
+ jdbc.update("INSERT INTO rmq_instance_topic (name,
instance_id, cluster_id, domain, status, topic_type)"
+ + " VALUES (?, ?, ?, ?, 'PENDING',
?)", resource.name(), instance.getName(), target,
+ domain, topicType == null ? "NORMAL" :
topicType);
} else {
- jdbc.update("INSERT INTO rmq_instance_group (name,
instance_id, cluster_id, status)"
- + " VALUES (?, ?, ?, 'PENDING')",
resource.name(), instance.getName(), cluster);
+ jdbc.update("INSERT INTO rmq_instance_group (name,
instance_id, cluster_id, domain, status)"
+ + " VALUES (?, ?, ?, ?, 'PENDING')",
resource.name(), instance.getName(), target,
+ domain);
}
} else {
- requireCluster(existing, cluster);
requireTopicType(existing, topicType);
}
});
} catch (DuplicateKeyException conflict) {
// The unique-key race happens before any RPC; never continue
remote writes after a unique-key failure.
- check(instance, resource, true);
+ checkSlot(instance, resource, target, true);
throw new BusinessException(409, "Resource is being registered
concurrently, please retry: " + resource.name());
} catch (PessimisticLockingFailureException conflict) {
throw new BusinessException(409, "Resource registration lock
conflict, please retry: " + resource.name());
}
return newTransaction().execute(status -> {
lockInstance(instance);
- Ownership existing = checkOwner(instance, resource, read(resource,
true), true);
- requireCluster(existing, cluster);
+ Ownership existing = checkOwner(instance, resource,
+ read(domain, resource, target,
true).stream().findFirst().orElse(null), true);
requireTopicType(existing, topicType);
return action.get();
});
@@ -156,7 +231,7 @@ public class ResourceOwnershipGuard {
lockInstance(instance);
resources.stream().distinct().sorted(Comparator.comparing((Resource r) ->
r.kind().name())
.thenComparing(Resource::name)).forEach(resource ->
- checkOwner(instance, resource, read(resource, true),
true));
+ checkOwnerInDomain(instance, resource, true));
return action.get();
});
}
@@ -220,8 +295,8 @@ public class ResourceOwnershipGuard {
lockIdentity(instance.getName());
if (instance.getId() == null) {
// A virtual instance must not be silently replaced by a
registered instance with the same name.
- if (!jdbc.query("SELECT id FROM rmq_instance WHERE name = ? FOR
UPDATE",
- (rs, row) -> rs.getLong(1), instance.getName()).isEmpty())
{
+ if (!jdbc.query("SELECT name FROM rmq_instance WHERE name = ? FOR
UPDATE",
+ (rs, row) -> rs.getString(1),
instance.getName()).isEmpty()) {
throw new BusinessException(409, "Virtual instance is shadowed
by a registered instance; please re-confirm ownership");
}
return;
@@ -239,16 +314,27 @@ public class ResourceOwnershipGuard {
}
}
- private Ownership read(Resource resource, boolean lock) {
+ private List<Ownership> read(String domain, Resource resource, String
cluster, boolean lock) {
String type = resource.kind() == Kind.TOPIC ? "topic_type" : "NULL";
- List<Ownership> rows = jdbc.query("SELECT id, name, instance_id,
cluster_id, " + type
- + " FROM " + resource.kind().table + " WHERE name = ?"
+ (lock ? " FOR UPDATE" : ""),
- (rs, row) -> new Ownership(rs.getLong(1), rs.getString(2),
rs.getString(3),
- rs.getString(4), rs.getString(5)), resource.name());
- if (rows.size() > 1) {
- throw new BusinessException(409, "Resource has duplicate ownership
records and needs manual resolution: " + resource.name());
+ StringBuilder sql = new StringBuilder("SELECT id, name, instance_id,
cluster_id, " + type + ", domain FROM "
+ + resource.kind().table + " WHERE domain = ? AND name = ?");
+ if (cluster != null) {
+ sql.append(" AND cluster_id = ?");
+ }
+ if (lock) {
+ sql.append(" FOR UPDATE");
}
- return rows.isEmpty() ? null : rows.getFirst();
+ String query = sql.toString();
+ return jdbc.query(connection -> {
+ var prepared = connection.prepareStatement(query);
+ prepared.setString(1, domain);
+ prepared.setString(2, resource.name());
+ if (cluster != null) {
+ prepared.setString(3, cluster);
+ }
+ return prepared;
+ }, (rs, row) -> new Ownership(rs.getLong(1), rs.getString(2),
rs.getString(3),
+ rs.getString(4), rs.getString(5), rs.getString(6)));
}
private Ownership checkOwner(InstanceVO instance, Resource resource,
Ownership owner, boolean required) {
@@ -258,21 +344,13 @@ public class ResourceOwnershipGuard {
}
return null;
}
- boolean legacyVirtual = !StringUtils.hasText(owner.instanceId()) &&
instance.getId() == null
- && instance.getName().equals(owner.clusterId());
- if (!instance.getName().equals(owner.instanceId()) && !legacyVirtual) {
+ if (!instance.getName().equals(owner.instanceId())) {
throw new BusinessException(409, "Resource " + resource.name() + "
belongs to instance: "
+ (StringUtils.hasText(owner.instanceId()) ?
owner.instanceId() : "unknown (legacy record, manual confirmation required)"));
}
return owner;
}
- private void requireCluster(Ownership owner, String cluster) {
- if (!Objects.equals(owner.clusterId(), cluster)) {
- throw new BusinessException(409, "Resource physical cluster does
not match the write target: " + owner.name());
- }
- }
-
private void requireTopicType(Ownership owner, String requested) {
if (requested != null && owner.topicType() != null &&
!requested.equals(owner.topicType())) {
throw new BusinessException(400, "topic message type is
immutable");
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipSchemaMigration.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipSchemaMigration.java
index 9efdff712..c09e65293 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipSchemaMigration.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipSchemaMigration.java
@@ -17,7 +17,7 @@
package org.apache.rocketmq.studio.instance;
import lombok.RequiredArgsConstructor;
-import
org.apache.rocketmq.studio.provider.apache.RocketMQDefaultClusterResolver;
+import org.apache.rocketmq.studio.cluster.nameserver.NamesrvAddrParser;
import org.springframework.beans.factory.InitializingBean;
import
org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitialization;
import org.springframework.stereotype.Component;
@@ -25,16 +25,24 @@ import org.springframework.util.StringUtils;
import javax.sql.DataSource;
import java.sql.Connection;
+import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
-/** Validates the migration after SQL initialization and before write services
start; never deduplicates or rewrites ownership. */
+/**
+ * Moves resource uniqueness from the bare name to the (domain, cluster, name)
slot: adds the missing
+ * columns on already initialized databases, resolves every instance to its
domain (registering a
+ * NameServer row for endpoints the registry does not know yet), backfills the
domain of every resource
+ * row, refuses to start on an unresolvable row or a duplicated slot, and only
then swaps the unique keys.
+ */
@Component
@DependsOnDatabaseInitialization
@RequiredArgsConstructor
@@ -43,52 +51,22 @@ public class ResourceOwnershipSchemaMigration implements
InitializingBean {
private static final int INDEX_METADATA_ATTEMPTS = 5;
private final DataSource dataSource;
- private final RocketMQDefaultClusterResolver defaultClusters;
@Override
public void afterPropertiesSet() throws Exception {
try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
- // Both tables are pre-checked; no DDL runs on any conflict and no
record is discarded.
+ ensureColumns(connection, statement);
+ Map<String, String> domains = backfillInstances(connection);
for (String kind : List.of("topic", "group")) {
- String table = "rmq_instance_" + kind;
- try (ResultSet rows = statement.executeQuery("SELECT name,
COUNT(*) FROM " + table
- + " GROUP BY name HAVING COUNT(*) > 1")) {
- if (rows.next()) {
- throw new IllegalStateException("Global resource
uniqueness migration failed: duplicate name requires manual resolution: "
- + table + "/" + rows.getString(1));
- }
- }
+ backfillResources(connection, "rmq_instance_" + kind, domains);
}
- List<String> virtualNames = null;
for (String kind : List.of("topic", "group")) {
- try (ResultSet rows = statement.executeQuery("SELECT r.name,
r.instance_id, r.cluster_id, i.name, owner.name"
- + " FROM rmq_instance_" + kind + " r LEFT JOIN
rmq_instance i ON i.name = r.cluster_id"
- + " LEFT JOIN rmq_instance owner ON owner.name =
r.instance_id")) {
- while (rows.next()) {
- if (!StringUtils.hasText(rows.getString(1)) ||
!StringUtils.hasText(rows.getString(3))
- ||
!rows.getString(1).equals(rows.getString(1).trim())
- ||
!rows.getString(3).equals(rows.getString(3).trim())) {
- throw new IllegalStateException("Resource
ownership cannot be determined and requires manual confirmation: " +
rows.getString(1));
- }
- if (!StringUtils.hasText(rows.getString(2))) {
- if (virtualNames == null) {
- virtualNames = defaultClusters.names();
- }
- if (rows.getString(4) != null ||
!virtualNames.contains(rows.getString(3))) {
- throw new IllegalStateException("Legacy empty
instance_id has no trusted virtual cluster mapping and requires manual
confirmation: "
- + rows.getString(1));
- }
- } else if
(!rows.getString(2).equals(rows.getString(2).trim())) {
- throw new IllegalStateException("Instance
ownership identifier is not normalized and requires manual confirmation: " +
rows.getString(1));
- } else if (rows.getString(5) == null) {
- if (virtualNames == null) {
- virtualNames = defaultClusters.names();
- }
- if (!rows.getString(2).equals(rows.getString(3))
- ||
!virtualNames.contains(rows.getString(3))) {
- throw new IllegalStateException("Owning
instance of the resource does not exist and has no trusted virtual mapping: " +
rows.getString(1));
- }
- }
+ String table = "rmq_instance_" + kind;
+ try (ResultSet rows = statement.executeQuery("SELECT name FROM
" + table
+ + " GROUP BY domain, cluster_id, name HAVING COUNT(*)
> 1")) {
+ if (rows.next()) {
+ throw new IllegalStateException("Resource uniqueness
migration failed: the same name is registered"
+ + " twice in one slot and needs manual
resolution: " + table + "/" + rows.getString(1));
}
}
}
@@ -100,40 +78,181 @@ public class ResourceOwnershipSchemaMigration implements
InitializingBean {
+ " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
for (String kind : List.of("topic", "group")) {
String table = "rmq_instance_" + kind;
- ensureIndex(connection, table, "uk_" + kind + "_name", "name",
true);
- ensureIndex(connection, table, "idx_" + kind + "_instance",
"instance_id", false);
- ensureIndex(connection, table, "idx_" + kind + "_cluster",
"cluster_id", false);
- // Create the global unique key before dropping the legacy
composite index; a mid-way failure keeps existing constraints.
- String legacy = "uk_cluster_instance_" + kind;
- if (indexes(connection, table).containsKey(legacy)) {
- try {
- statement.executeUpdate("ALTER TABLE " + table + "
DROP INDEX " + legacy);
- } catch (SQLException failure) {
- if (indexes(connection, table).containsKey(legacy)) {
- throw failure;
- }
- }
+ // Create the slot unique key before dropping the name-only
one; a mid-way failure keeps existing constraints.
+ ensureIndex(connection, table, "uk_" + kind + "_domain",
List.of("domain", "cluster_id", "name"), true);
+ ensureIndex(connection, table, "idx_" + kind + "_instance",
List.of("instance_id"), false);
+ ensureIndex(connection, table, "idx_" + kind + "_cluster",
List.of("cluster_id"), false);
+ ensureIndex(connection, table, "idx_" + kind + "_domain",
List.of("domain"), false);
+ dropIndex(connection, table, "uk_" + kind + "_name");
+ dropIndex(connection, table, "uk_cluster_instance_" + kind);
+ }
+ }
+ }
+
+ private void ensureColumns(Connection connection, Statement statement)
throws SQLException {
+ addColumnIfMissing(connection, statement, "rmq_instance",
"nameserver_id",
+ "bigint(20) unsigned COMMENT 'bound rmq_nameserver.id;
validated in the application layer, no foreign key'");
+ for (String kind : List.of("topic", "group")) {
+ addColumnIfMissing(connection, statement, "rmq_instance_" + kind,
"domain",
+ "VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'uniqueness
domain: ns:<nameserver_id> or cloud:<cloud_instance_id>'");
+ }
+ }
+
+ private void addColumnIfMissing(Connection connection, Statement
statement, String table, String column,
+ String definition) throws SQLException {
+ if (columns(connection, table).contains(column)) {
+ return;
+ }
+ statement.executeUpdate("ALTER TABLE " + table + " ADD COLUMN " +
column + " " + definition);
+ }
+
+ private List<String> columns(Connection connection, String table) throws
SQLException {
+ List<String> columns = new ArrayList<>();
+ try (ResultSet rows =
connection.getMetaData().getColumns(connection.getCatalog(), null, table,
null)) {
+ while (rows.next()) {
+
columns.add(rows.getString("COLUMN_NAME").toLowerCase(Locale.ROOT));
+ }
+ }
+ return columns;
+ }
+
+ /** Resolves every instance to its domain, registering a NameServer row
for endpoints the registry does not know yet. */
+ private Map<String, String> backfillInstances(Connection connection)
throws SQLException {
+ Map<String, String> domains = new LinkedHashMap<>();
+ List<Long> ids = new ArrayList<>();
+ List<String> names = new ArrayList<>();
+ List<String> endpoints = new ArrayList<>();
+ List<String> vendors = new ArrayList<>();
+ List<String> cloudIds = new ArrayList<>();
+ List<Long> nameserverIds = new ArrayList<>();
+ try (Statement statement = connection.createStatement();
+ ResultSet rows = statement.executeQuery("SELECT id, name,
endpoint, vendor, cloud_instance_id,"
+ + " nameserver_id FROM rmq_instance")) {
+ while (rows.next()) {
+ ids.add(rows.getLong(1));
+ names.add(rows.getString(2));
+ endpoints.add(rows.getString(3));
+ vendors.add(rows.getString(4));
+ cloudIds.add(rows.getString(5));
+ nameserverIds.add(rows.getLong(6));
+ }
+ }
+ for (int i = 0; i < ids.size(); i++) {
+ String vendor = vendors.get(i);
+ if (StringUtils.hasText(vendor) && !"APACHE".equals(vendor)) {
+ if (!StringUtils.hasText(cloudIds.get(i))) {
+ throw new IllegalStateException("Cloud instance has no
cloud_instance_id and cannot be placed in a"
+ + " uniqueness domain; manual resolution required:
" + names.get(i));
}
+ domains.put(names.get(i),
ResourceOwnershipGuard.cloudDomain(cloudIds.get(i)));
+ continue;
+ }
+ if (nameserverIds.get(i) > 0) {
+ domains.put(names.get(i),
ResourceOwnershipGuard.nameserverDomain(nameserverIds.get(i)));
+ continue;
+ }
+ long nameserverId = requireNameserverId(connection, names.get(i),
endpoints.get(i));
+ try (PreparedStatement update =
connection.prepareStatement("UPDATE rmq_instance SET nameserver_id = ? WHERE id
= ?")) {
+ update.setLong(1, nameserverId);
+ update.setLong(2, ids.get(i));
+ update.executeUpdate();
}
+ domains.put(names.get(i),
ResourceOwnershipGuard.nameserverDomain(nameserverId));
}
+ return domains;
}
- private void ensureIndex(Connection connection, String table, String name,
String column, boolean unique)
+ private long requireNameserverId(Connection connection, String
instanceName, String endpoint) throws SQLException {
+ String normalized = NamesrvAddrParser.normalize(endpoint);
+ try (PreparedStatement select = connection.prepareStatement("SELECT id
FROM rmq_nameserver WHERE namesrv_addr = ?")) {
+ select.setString(1, normalized);
+ try (ResultSet rows = select.executeQuery()) {
+ if (rows.next()) {
+ return rows.getLong(1);
+ }
+ }
+ }
+ String name = "ns-" + instanceName;
+ try (PreparedStatement existing = connection.prepareStatement("SELECT
COUNT(*) FROM rmq_nameserver WHERE name = ?")) {
+ existing.setString(1, name);
+ try (ResultSet rows = existing.executeQuery()) {
+ rows.next();
+ if (rows.getLong(1) > 0) {
+ name = name + "-" + System.nanoTime();
+ }
+ }
+ }
+ try (PreparedStatement insert = connection.prepareStatement(
+ "INSERT INTO rmq_nameserver (name, namesrv_addr, status)
VALUES (?, ?, 'healthy')",
+ Statement.RETURN_GENERATED_KEYS)) {
+ insert.setString(1, name);
+ insert.setString(2, normalized);
+ insert.executeUpdate();
+ try (ResultSet keys = insert.getGeneratedKeys()) {
+ if (!keys.next()) {
+ throw new IllegalStateException("NameServer registration
returned no id for instance: " + instanceName);
+ }
+ return keys.getLong(1);
+ }
+ }
+ }
+
+ private void backfillResources(Connection connection, String table,
Map<String, String> domains) throws SQLException {
+ List<Long> ids = new ArrayList<>();
+ List<String> owners = new ArrayList<>();
+ try (Statement statement = connection.createStatement();
+ ResultSet rows = statement.executeQuery("SELECT id, instance_id,
cluster_id FROM " + table
+ + " WHERE domain = '' OR domain IS NULL")) {
+ while (rows.next()) {
+ ids.add(rows.getLong(1));
+ // Legacy unclaimed rows fall back to their cluster, which
used to double as the virtual instance name.
+ String instanceId = rows.getString(2);
+ owners.add(StringUtils.hasText(instanceId) ? instanceId :
rows.getString(3));
+ }
+ }
+ for (int i = 0; i < ids.size(); i++) {
+ String domain = domains.get(owners.get(i));
+ if (domain == null) {
+ throw new IllegalStateException("Resource ownership cannot be
determined and requires manual confirmation: "
+ + table + "/" + ids.get(i) + " owner=" +
owners.get(i));
+ }
+ try (PreparedStatement update =
connection.prepareStatement("UPDATE " + table + " SET domain = ? WHERE id =
?")) {
+ update.setString(1, domain);
+ update.setLong(2, ids.get(i));
+ update.executeUpdate();
+ }
+ }
+ }
+
+ private void ensureIndex(Connection connection, String table, String name,
List<String> columns, boolean unique)
throws SQLException {
Map<String, List<String>> indexes = indexes(connection, table);
- String expected = (unique ? "unique:" : "index:") + column;
+ List<String> expected = columns.stream().map(column -> (unique ?
"unique:" : "index:") + column).toList();
if (indexes.containsKey(name)) {
- if (!indexes.get(name).equals(List.of(expected))) {
+ if (!indexes.get(name).equals(expected)) {
throw new IllegalStateException("Index definition violates the
ownership constraint: " + table + "." + name);
}
return;
}
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("CREATE " + (unique ? "UNIQUE " : "") +
"INDEX " + name
- + " ON " + table + " (" + column + ")");
+ + " ON " + table + " (" + String.join(", ", columns) +
")");
} catch (SQLException failure) {
// Concurrent startups only accept an identical index; failures
caused by duplicate data must not be swallowed.
- if (!List.of(expected).equals(indexes(connection,
table).get(name))) {
+ if (!expected.equals(indexes(connection, table).get(name))) {
+ throw failure;
+ }
+ }
+ }
+
+ private void dropIndex(Connection connection, String table, String name)
throws SQLException {
+ if (!indexes(connection, table).containsKey(name)) {
+ return;
+ }
+ try (Statement statement = connection.createStatement()) {
+ statement.executeUpdate("ALTER TABLE " + table + " DROP INDEX " +
name);
+ } catch (SQLException failure) {
+ if (indexes(connection, table).containsKey(name)) {
throw failure;
}
}
@@ -167,9 +286,9 @@ public class ResourceOwnershipSchemaMigration implements
InitializingBean {
String name = rows.getString("INDEX_NAME");
String column = rows.getString("COLUMN_NAME");
if (name != null && column != null) {
-
indexes.computeIfAbsent(name.toLowerCase(java.util.Locale.ROOT), ignored -> new
ArrayList<>())
+ indexes.computeIfAbsent(name.toLowerCase(Locale.ROOT),
ignored -> new ArrayList<>())
.add((rows.getBoolean("NON_UNIQUE") ? "index:" :
"unique:")
- +
column.toLowerCase(java.util.Locale.ROOT));
+ + column.toLowerCase(Locale.ROOT));
}
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/UpdateInstanceDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/UpdateInstanceDTO.java
index 63f48847b..06b38962f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/UpdateInstanceDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/UpdateInstanceDTO.java
@@ -36,6 +36,8 @@ public class UpdateInstanceDTO {
private String adminCredentialRef;
+ private Long nameserverId;
+
public InstanceVO toInstanceVO() {
return InstanceVO.builder()
.name(name)
@@ -43,6 +45,7 @@ public class UpdateInstanceDTO {
.endpoint(endpoint)
.remark(remark)
.adminCredentialRef(adminCredentialRef)
+ .nameserverId(nameserverId)
.build();
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
index d5f8b7267..91f3f4aa5 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
@@ -176,7 +176,7 @@ public class MessageService {
List<Resource> resources = List.of(new Resource(Kind.GROUP,
request.getConsumerGroup()),
ownershipGuard.topicResource(request.getTopic()));
if (apache) {
- resources.forEach(resource -> ownershipGuard.check(instance,
resource, true));
+ resources.forEach(resource ->
ownershipGuard.checkOwnerInDomain(instance, resource, true));
ownershipGuard.requireSupportedProvider(instance);
}
String detail = "topic=" + request.getTopic() + ", consumerGroup=" +
request.getConsumerGroup()
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
index faaeccdf9..4fed84f50 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
@@ -71,7 +71,7 @@ public class LiteTopicService {
var resource = ownershipGuard.topicResource(topicPattern);
// Ownership guard applies to open-source Apache instances only; cloud
calls are isolated by their own instance id.
if (instance.getVendor() == null || instance.getVendor() ==
InstanceVendor.APACHE) {
- ownershipGuard.check(instance, resource, true);
+ ownershipGuard.checkSlot(instance, resource, null, true);
ownershipGuard.requireSupportedProvider(instance);
}
if (resource.kind() != ResourceOwnershipGuard.Kind.TOPIC) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index 64bb7720e..039a45d5c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -353,7 +353,7 @@ public class MetadataService {
if (retrySourceTopic != null &&
!retrySourceTopic.equals(original.getTopic())) {
resources.add(ownershipGuard.topicResource(retrySourceTopic));
}
- resources.forEach(resource -> ownershipGuard.check(instance, resource,
true));
+ resources.forEach(resource ->
ownershipGuard.checkOwnerInDomain(instance, resource, true));
return ownershipGuard.withOwned(instance, resources, () ->
sendMessage(request, retrySourceTopic));
}
@@ -754,7 +754,9 @@ public class MetadataService {
InstanceVO instance = ownershipGuard.requireInstance(instanceId);
// Ownership guard applies to open-source Apache instances only; cloud
calls are isolated by their own instance id.
if (instance.getVendor() == null || instance.getVendor() ==
InstanceVendor.APACHE) {
- ownershipGuard.check(instance, resource, required);
+ if (required) {
+ ownershipGuard.checkOwnerInDomain(instance, resource, true);
+ }
ownershipGuard.requireSupportedProvider(instance);
}
return instance.getName();
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqGroup.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqGroup.java
index 959987243..879949991 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqGroup.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqGroup.java
@@ -34,6 +34,8 @@ public class RmqGroup {
private String instanceId;
+ private String domain;
+
private String name;
private String consumeType;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqInstance.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqInstance.java
index 11d7715f5..127418624 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqInstance.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqInstance.java
@@ -48,6 +48,8 @@ public class RmqInstance {
private String regionId;
+ private Long nameserverId;
+
private LocalDateTime gmtCreate;
private LocalDateTime gmtModified;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTopic.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTopic.java
index d2ec34350..7585ef2bf 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTopic.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTopic.java
@@ -34,6 +34,8 @@ public class RmqTopic {
private String instanceId;
+ private String domain;
+
private String name;
private String topicType;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
index 1c718170f..3204b1677 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
@@ -280,7 +280,9 @@ public class RocketMQAdminClientImpl implements AdminClient
{
}
private TopicVO createTopic(String instanceId, TopicVO topic, boolean
importing) {
- String target = ownershipGuard.requireInstance(instanceId).getName();
+ InstanceVO instance = ownershipGuard.requireInstance(instanceId);
+ String target = instance.getName();
+ String domain = ownershipGuard.domainOf(instance);
if (topic == null) {
throw new BusinessException(400, "Topic request is required");
}
@@ -290,9 +292,12 @@ public class RocketMQAdminClientImpl implements
AdminClient {
int writeQueues = topic.getWriteQueues() > 0 ? topic.getWriteQueues()
: 8;
int readQueues = topic.getReadQueues() > 0 ? topic.getReadQueues() : 8;
- return executeResourceWrite(target, Kind.TOPIC, topicName, true,
topic.getType(), (admin, clusterName) -> {
+ var write = new WriteRequest(true, topic.getType(),
topic.getClusterId());
+ return executeResourceWrite(target, Kind.TOPIC, topicName, write,
(admin, clusterName) -> {
try {
RmqTopic existing = topicMapper.selectOne(new
LambdaQueryWrapper<RmqTopic>()
+ .eq(RmqTopic::getDomain, domain)
+ .eq(RmqTopic::getClusterId, clusterName)
.eq(RmqTopic::getName, topicName));
TopicPerm effectivePerm = topic.getPerm() != null
? topic.getPerm()
@@ -318,12 +323,15 @@ public class RocketMQAdminClientImpl implements
AdminClient {
// The global reservation was already committed independently
before the RPC; here we only update the unique record.
RmqTopic entity = topicMapper.selectOne(new
LambdaQueryWrapper<RmqTopic>()
+ .eq(RmqTopic::getDomain, domain)
+ .eq(RmqTopic::getClusterId, clusterName)
.eq(RmqTopic::getName, topicName));
boolean isNew = entity == null;
if (isNew) {
entity = new RmqTopic();
entity.setName(topicName);
entity.setClusterId(clusterName);
+ entity.setDomain(domain);
entity.setInstanceId(target);
entity.setGmtCreate(LocalDateTime.now());
}
@@ -365,14 +373,19 @@ public class RocketMQAdminClientImpl implements
AdminClient {
@Override
public TopicVO updateTopic(String instanceId, TopicVO topic) {
- String target = ownershipGuard.requireInstance(instanceId).getName();
+ InstanceVO instance = ownershipGuard.requireInstance(instanceId);
+ String target = instance.getName();
+ String domain = ownershipGuard.domainOf(instance);
topic.setInstanceId(target);
topic.setName(ResourceOwnershipGuard.requireText(topic.getName(),
"topicName"));
String topicName = topic.getName();
- return executeResourceWrite(target, Kind.TOPIC, topicName, false,
topic.getType(), (admin, clusterName) -> {
+ var write = new WriteRequest(false, topic.getType(),
topic.getClusterId());
+ return executeResourceWrite(target, Kind.TOPIC, topicName, write,
(admin, clusterName) -> {
try {
RmqTopic existing = topicMapper.selectOne(new
LambdaQueryWrapper<RmqTopic>()
+ .eq(RmqTopic::getDomain, domain)
+ .eq(RmqTopic::getClusterId, clusterName)
.eq(RmqTopic::getName, topicName));
if (existing == null) {
throw new BusinessException(404, "Topic not found: " +
topicName);
@@ -492,21 +505,26 @@ public class RocketMQAdminClientImpl implements
AdminClient {
Resource resource = ownershipGuard.topicResource(name);
if (resource.kind() != Kind.TOPIC) {
ownershipGuard.withOwned(instance, List.of(resource), () ->
executeForInstance(instance.getName(), admin -> {
- var owner = ownershipGuard.check(instance, resource, true);
+ var owner = ownershipGuard.checkOwnerInDomain(instance,
resource, true);
var target = ApacheWriteTargetResolver.resolve(admin,
instance, owner.clusterId());
deletePhysicalTopic(admin, instance.getName(), name,
target.cluster(), target.masters());
return null;
}));
return;
}
- executeResourceWrite(instance.getName(), Kind.TOPIC, name, false,
null, (admin, clusterName) -> {
+ ownershipGuard.checkOwnerInDomain(instance, resource, true);
+ ownershipGuard.withOwned(instance, List.of(resource), () ->
executeForInstance(instance.getName(), admin -> {
+ List<ResourceOwnershipGuard.Ownership> slots =
ownershipGuard.slots(instance, resource);
try {
- Set<String> brokerAddrs =
getMasterBrokerAddrsForCluster(admin, clusterName);
-
- deletePhysicalTopic(admin, instance.getName(), name,
clusterName, brokerAddrs);
- topicMapper.delete(new
LambdaQueryWrapper<RmqTopic>().eq(RmqTopic::getName, name));
+ for (ResourceOwnershipGuard.Ownership slot : slots) {
+ deletePhysicalTopic(admin, instance.getName(), name,
slot.clusterId(),
+ getMasterBrokerAddrsForCluster(admin,
slot.clusterId()));
+ }
+ topicMapper.delete(new LambdaQueryWrapper<RmqTopic>()
+ .eq(RmqTopic::getDomain,
ownershipGuard.domainOf(instance))
+ .eq(RmqTopic::getName, name));
- recordAudit("DELETE_TOPIC", name, "", "SUCCESS");
+ recordAudit("DELETE_TOPIC", name, "slots=" + slots.size(),
"SUCCESS");
return null;
} catch (BusinessException e) {
recordAudit("DELETE_TOPIC", name, e.getMessage(), "FAILED");
@@ -515,7 +533,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
recordAudit("DELETE_TOPIC", name, e.getMessage(), "FAILED");
throw classifyBrokerFailure(e, "delete topic");
}
- });
+ }));
}
@Override
@@ -539,10 +557,10 @@ public class RocketMQAdminClientImpl implements
AdminClient {
request.setInstanceId(instance.getName());
request.setTopic(ResourceOwnershipGuard.requireText(request.getTopic(),
"topicName"));
Resource resource = ownershipGuard.topicResource(request.getTopic());
- ownershipGuard.check(instance, resource, true);
+ ownershipGuard.checkOwnerInDomain(instance, resource, true);
return ownershipGuard.withOwned(instance, List.of(resource), () -> {
executeForInstance(instance.getName(), admin -> {
- var owner = ownershipGuard.check(instance, resource, true);
+ var owner = ownershipGuard.checkOwnerInDomain(instance,
resource, true);
var target = ApacheWriteTargetResolver.resolve(admin,
instance, owner.clusterId());
ApacheWriteTargetResolver.requireTopicRoute(admin, target,
request.getTopic());
return null;
@@ -675,19 +693,25 @@ public class RocketMQAdminClientImpl implements
AdminClient {
}
private ConsumerGroupVO createConsumerGroup(ConsumerGroupVO group, boolean
importing) {
- String instanceId = ownershipGuard.requireInstance(group == null ?
null : group.getInstanceId()).getName();
+ InstanceVO instance = ownershipGuard.requireInstance(group == null ?
null : group.getInstanceId());
+ String instanceId = instance.getName();
+ String domain = ownershipGuard.domainOf(instance);
group.setInstanceId(instanceId);
group.setName(ResourceOwnershipGuard.requireText(group.getName(),
"groupName"));
- return executeResourceWrite(instanceId, Kind.GROUP, group.getName(),
true, null,
- (admin, cluster) -> createConsumerGroup(admin, group, cluster,
importing));
+ var write = new WriteRequest(true, null, group.getClusterId());
+ return executeResourceWrite(instanceId, Kind.GROUP, group.getName(),
write,
+ (admin, cluster) -> createConsumerGroup(admin, group, cluster,
domain, importing));
}
@Override
public ConsumerGroupVO updateConsumerGroup(ConsumerGroupVO group) {
- String instanceId = ownershipGuard.requireInstance(group == null ?
null : group.getInstanceId()).getName();
+ InstanceVO instance = ownershipGuard.requireInstance(group == null ?
null : group.getInstanceId());
+ String instanceId = instance.getName();
+ String domain = ownershipGuard.domainOf(instance);
group.setInstanceId(instanceId);
group.setName(ResourceOwnershipGuard.requireText(group.getName(),
"groupName"));
- return executeResourceWrite(instanceId, Kind.GROUP, group.getName(),
false, null, (admin, clusterName) -> {
+ var write = new WriteRequest(false, null, group.getClusterId());
+ return executeResourceWrite(instanceId, Kind.GROUP, group.getName(),
write, (admin, clusterName) -> {
String groupName = group.getName();
int totalBrokers = 0;
int updatedBrokers = 0;
@@ -715,7 +739,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
updatedBrokers++;
}
- persistConsumerGroup(group, clusterName,
group.getRetryMaxTimes());
+ persistConsumerGroup(group, clusterName, domain,
group.getRetryMaxTimes());
recordAudit("UPDATE_GROUP", groupName, "retryMaxTimes=" +
group.getRetryMaxTimes()
+ ", brokersUpdated=" + updatedBrokers + "/" +
totalBrokers, "SUCCESS");
return group;
@@ -736,7 +760,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
return executeForInstance(instanceId, admin -> {
try {
InstanceVO instance =
ownershipGuard.requireInstance(instanceId);
- var owner = ownershipGuard.check(instance, new
Resource(Kind.GROUP, name), true);
+ var owner = ownershipGuard.checkSlot(instance, new
Resource(Kind.GROUP, name), null, true);
Set<String> brokerAddrs =
ApacheWriteTargetResolver.resolve(admin, instance, owner.clusterId()).masters();
if (brokerAddrs.isEmpty()) {
throw new BusinessException(502, "No broker available to
get consumer group settings");
@@ -761,7 +785,8 @@ public class RocketMQAdminClientImpl implements AdminClient
{
public ConsumerGroupSettingsVO updateConsumerGroupSettings(String
instanceId, String rawName,
ConsumerGroupSettingsCommand command) {
String name = ResourceOwnershipGuard.requireText(rawName, "groupName");
- return executeResourceWrite(instanceId, Kind.GROUP, name, false, null,
(admin, clusterName) -> {
+ var write = new WriteRequest(false, null, null);
+ return executeResourceWrite(instanceId, Kind.GROUP, name, write,
(admin, clusterName) -> {
int totalBrokers = 0;
int updatedBrokers = 0;
try {
@@ -836,7 +861,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
}
private ConsumerGroupVO createConsumerGroup(MQAdminExt admin,
ConsumerGroupVO group, String groupClusterName,
- boolean importing) {
+ String domain, boolean
importing) {
String groupName = group.getName();
try {
@@ -865,7 +890,7 @@ public class RocketMQAdminClientImpl implements AdminClient
{
}
}
- persistConsumerGroup(group, groupClusterName,
config.getRetryMaxTimes());
+ persistConsumerGroup(group, groupClusterName, domain,
config.getRetryMaxTimes());
recordAudit("CREATE_GROUP", groupName,
"retryMaxTimes=" + config.getRetryMaxTimes()
+ ", consumeMessageOrderly=" +
consumeMessageOrderly, "SUCCESS");
@@ -893,15 +918,18 @@ public class RocketMQAdminClientImpl implements
AdminClient {
return normalized.contains("FIFO") || normalized.contains("ORDER");
}
- private void persistConsumerGroup(ConsumerGroupVO group, String
clusterName, int retryMaxTimes) {
- // Ownership is determined by the global reservation guard; a
three-column filter must not mask cross-instance conflicts.
+ private void persistConsumerGroup(ConsumerGroupVO group, String
clusterName, String domain, int retryMaxTimes) {
+ // Ownership is determined by the slot reservation guard; the slot key
must not mask cross-instance conflicts.
RmqGroup entity = groupMapper.selectOne(new
LambdaQueryWrapper<RmqGroup>()
+ .eq(RmqGroup::getDomain, domain)
+ .eq(RmqGroup::getClusterId, clusterName)
.eq(RmqGroup::getName, group.getName()));
boolean isNewGroup = entity == null;
if (isNewGroup) {
entity = new RmqGroup();
entity.setName(group.getName());
entity.setClusterId(clusterName);
+ entity.setDomain(domain);
entity.setInstanceId(group.getInstanceId());
entity.setGmtCreate(LocalDateTime.now());
}
@@ -920,15 +948,21 @@ public class RocketMQAdminClientImpl implements
AdminClient {
@Override
public void deleteConsumerGroup(String instanceId, String rawName) {
- String target = ownershipGuard.requireInstance(instanceId).getName();
+ InstanceVO instance = ownershipGuard.requireInstance(instanceId);
+ String target = instance.getName();
String name = ResourceOwnershipGuard.requireText(rawName, "groupName");
- executeResourceWrite(target, Kind.GROUP, name, false, null, (admin,
clusterName) -> {
- doDeleteConsumerGroup(target, admin, name, clusterName);
+ Resource resource = new Resource(Kind.GROUP, name);
+ ownershipGuard.checkOwnerInDomain(instance, resource, true);
+ ownershipGuard.withOwned(instance, List.of(resource), () ->
executeForInstance(target, admin -> {
+ for (ResourceOwnershipGuard.Ownership slot :
ownershipGuard.slots(instance, resource)) {
+ doDeleteConsumerGroup(target, admin, name, slot.clusterId(),
slot.domain());
+ }
return null;
- });
+ }));
}
- private void doDeleteConsumerGroup(String instanceId, MQAdminExt admin,
String name, String clusterName) {
+ private void doDeleteConsumerGroup(String instanceId, MQAdminExt admin,
String name, String clusterName,
+ String domain) {
try {
Set<String> brokerAddrs = getMasterBrokerAddrsForCluster(admin,
clusterName);
@@ -940,7 +974,10 @@ public class RocketMQAdminClientImpl implements
AdminClient {
// Delete derived resources while holding group ownership; on
failure keep the reservation so the original instance can retry.
deletePhysicalTopic(admin, instanceId, "%DLQ%" + name,
clusterName, brokerAddrs);
deletePhysicalTopic(admin, instanceId, "%RETRY%" + name,
clusterName, brokerAddrs);
- groupMapper.delete(new
LambdaQueryWrapper<RmqGroup>().eq(RmqGroup::getName, name));
+ groupMapper.delete(new LambdaQueryWrapper<RmqGroup>()
+ .eq(RmqGroup::getDomain, domain)
+ .eq(RmqGroup::getClusterId, clusterName)
+ .eq(RmqGroup::getName, name));
recordAudit("DELETE_GROUP", name, "", "SUCCESS");
} catch (BusinessException e) {
@@ -1209,12 +1246,12 @@ public class RocketMQAdminClientImpl implements
AdminClient {
InstanceVO instance = ownershipGuard.requireInstance(instanceId);
Resource group = new Resource(Kind.GROUP,
ResourceOwnershipGuard.requireText(name, "groupName"));
Resource topicResource = ownershipGuard.topicResource(topic);
- ownershipGuard.check(instance, group, true);
- ownershipGuard.check(instance, topicResource, true);
+ ownershipGuard.checkOwnerInDomain(instance, group, true);
+ ownershipGuard.checkOwnerInDomain(instance, topicResource, true);
ownershipGuard.withOwned(instance, List.of(group, topicResource),
() ->
executeForInstance(instance.getName(), admin -> {
- var groupOwner = ownershipGuard.check(instance, group,
true);
- var topicOwner = ownershipGuard.check(instance,
topicResource, true);
+ var groupOwner =
ownershipGuard.checkOwnerInDomain(instance, group, true);
+ var topicOwner =
ownershipGuard.checkOwnerInDomain(instance, topicResource, true);
if
(!groupOwner.clusterId().equals(topicOwner.clusterId())) {
throw new BusinessException(409, "Group and topic
are not in the same target cluster");
}
@@ -1292,22 +1329,29 @@ public class RocketMQAdminClientImpl implements
AdminClient {
return
runtimeAdminClientResolver.execute(ResourceOwnershipGuard.requireText(instanceId,
"instanceId"), action);
}
- private <T> T executeResourceWrite(String instanceId, Kind kind, String
name, boolean create, TopicType type,
+ private <T> T executeResourceWrite(String instanceId, Kind kind, String
name, WriteRequest request,
java.util.function.BiFunction<MQAdminExt, String, T> action) {
InstanceVO instance = ownershipGuard.requireInstance(instanceId);
Resource resource = new Resource(kind,
ResourceOwnershipGuard.requireText(name, "resourceName"));
- if (create && kind == Kind.TOPIC &&
ownershipGuard.topicResource(name).kind() != Kind.TOPIC) {
+ if (request.create() && kind == Kind.TOPIC &&
ownershipGuard.topicResource(name).kind() != Kind.TOPIC) {
throw new BusinessException(400, "Derived resources cannot be
registered as a regular topic");
}
- var owner = ownershipGuard.check(instance, resource, !create);
+ var owner = request.create()
+ ? ownershipGuard.createTarget(instance, resource)
+ : ownershipGuard.checkSlot(instance, resource, null, true);
ownershipGuard.requireSupportedProvider(instance);
return executeForInstance(instance.getName(), admin -> {
- var target = ApacheWriteTargetResolver.resolve(admin, instance,
owner == null ? null : owner.clusterId());
- return ownershipGuard.write(instance, resource, target.cluster(),
create,
- type == null ? null : type.name(), () ->
action.apply(admin, target.cluster()));
+ var target = ApacheWriteTargetResolver.resolve(admin, instance,
+ owner == null ? request.cluster() : owner.clusterId());
+ return ownershipGuard.write(instance, resource, target.cluster(),
request.create(),
+ request.type() == null ? null : request.type().name(),
+ () -> action.apply(admin, target.cluster()));
});
}
+ /** What a registration write intends: create or update, the message type,
and an explicit physical cluster. */
+ private record WriteRequest(boolean create, TopicType type, String
cluster) { }
+
/** Import reads all target masters within the ownership row lock;
existing config is only registered, never overwritten. */
private boolean verifyTopicImport(MQAdminExt admin, Set<String> masters,
TopicConfig requested) throws Exception {
boolean found = false;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
index 4211ccd6f..06ccfb960 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
@@ -370,7 +370,7 @@ public class RocketMQLiteTopicProvider implements
LiteTopicProvider {
var instance = ownershipGuard.requireInstance(instanceId);
String topicPattern =
ResourceOwnershipGuard.requireText(rawTopicPattern, "topicPattern");
var resource = ownershipGuard.topicResource(topicPattern);
- ownershipGuard.check(instance, resource, true);
+ ownershipGuard.checkSlot(instance, resource, null, true);
ownershipGuard.requireSupportedProvider(instance);
if (resource.kind() != ResourceOwnershipGuard.Kind.TOPIC) {
throw new BusinessException(409, "TTL can only be updated for a
registered Lite parent topic");
@@ -380,7 +380,7 @@ public class RocketMQLiteTopicProvider implements
LiteTopicProvider {
}
long minutes = Math.min(Math.max(Math.round(ttlMillis / 60000.0), 1),
MAX_LITE_TTL_MINUTES);
ownershipGuard.withOwned(instance, List.of(resource), () ->
runtimeAdminClientResolver.execute(instance, admin -> {
- var owner = ownershipGuard.check(instance, resource, true);
+ var owner = ownershipGuard.checkSlot(instance, resource, null,
true);
var target = ApacheWriteTargetResolver.resolve(admin, instance,
owner.clusterId());
// Read every master's config before writing any of them: a master
that cannot be
// examined must fail the request up front instead of being
silently skipped, which
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
index 4afed6fb9..4a4116fb9 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
@@ -558,12 +558,12 @@ public class RocketMQMessageProvider implements
MessageProvider {
String messageId =
ResourceOwnershipGuard.requireText(request.getMsgId(), "msgId");
Resource topicResource = ownershipGuard.topicResource(topic);
Resource groupResource = new Resource(Kind.GROUP, group);
- ownershipGuard.check(instance, topicResource, true);
- ownershipGuard.check(instance, groupResource, true);
+ ownershipGuard.checkOwnerInDomain(instance, topicResource, true);
+ ownershipGuard.checkOwnerInDomain(instance, groupResource, true);
return ownershipGuard.withOwned(instance, List.of(topicResource,
groupResource), () ->
runtimeAdminClientResolver.execute(instance.getName(), admin
-> {
- var topicOwner = ownershipGuard.check(instance,
topicResource, true);
- var groupOwner = ownershipGuard.check(instance,
groupResource, true);
+ var topicOwner =
ownershipGuard.checkOwnerInDomain(instance, topicResource, true);
+ var groupOwner =
ownershipGuard.checkOwnerInDomain(instance, groupResource, true);
if
(!topicOwner.clusterId().equals(groupOwner.clusterId())) {
throw new BusinessException(409, "Group and topic are
not in the same target cluster");
}
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index 094468060..c44222b0a 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -101,9 +101,11 @@ CREATE TABLE IF NOT EXISTS rmq_instance (
credential_id bigint(20) unsigned COMMENT '引用 rmq_cloud_credential.id',
admin_credential_ref VARCHAR(128) COMMENT 'External Apache admin credential
reference; no secret material',
region_id VARCHAR(128),
+ nameserver_id bigint(20) unsigned COMMENT '绑定的
rmq_nameserver.id;应用层校验存在性,不加外键;APACHE 实例创建时必填且不可修改',
PRIMARY KEY (`id`),
UNIQUE KEY uk_instance_name (name),
INDEX idx_instance_credential_id (credential_id),
+ INDEX idx_instance_nameserver_id (nameserver_id),
CONSTRAINT fk_instance_cloud_credential FOREIGN KEY (credential_id)
REFERENCES rmq_cloud_credential(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -125,6 +127,7 @@ CREATE TABLE IF NOT EXISTS rmq_instance_topic (
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP COMMENT '修改时间',
cluster_id VARCHAR(64) NOT NULL,
instance_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '归属实例
ID(rmq_instance.name;空字符串表示历史未归属记录)',
+ domain VARCHAR(128) NOT NULL DEFAULT '' COMMENT '唯一性域:ns:<nameserver_id> 或
cloud:<cloud_instance_id>',
name VARCHAR(255) NOT NULL,
topic_type VARCHAR(32) DEFAULT 'NORMAL',
read_queue_nums INT DEFAULT 8,
@@ -134,9 +137,10 @@ CREATE TABLE IF NOT EXISTS rmq_instance_topic (
status VARCHAR(32) DEFAULT 'ACTIVE',
created_by VARCHAR(64),
PRIMARY KEY (`id`),
- UNIQUE KEY uk_topic_name (name),
+ UNIQUE KEY uk_topic_domain (domain, cluster_id, name),
INDEX idx_topic_instance (instance_id),
- INDEX idx_topic_cluster (cluster_id)
+ INDEX idx_topic_cluster (cluster_id),
+ INDEX idx_topic_domain (domain)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. Consumer Group 管理记录
@@ -146,6 +150,7 @@ CREATE TABLE IF NOT EXISTS rmq_instance_group (
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP COMMENT '修改时间',
cluster_id VARCHAR(64) NOT NULL,
instance_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '归属实例
ID(rmq_instance.name;空字符串表示历史未归属记录)',
+ domain VARCHAR(128) NOT NULL DEFAULT '' COMMENT '唯一性域:ns:<nameserver_id> 或
cloud:<cloud_instance_id>',
name VARCHAR(255) NOT NULL,
consume_type VARCHAR(32) DEFAULT 'CONCURRENTLY',
message_model VARCHAR(32) DEFAULT 'CLUSTERING',
@@ -153,9 +158,10 @@ CREATE TABLE IF NOT EXISTS rmq_instance_group (
status VARCHAR(32) DEFAULT 'ACTIVE',
created_by VARCHAR(64),
PRIMARY KEY (`id`),
- UNIQUE KEY uk_group_name (name),
+ UNIQUE KEY uk_group_domain (domain, cluster_id, name),
INDEX idx_group_instance (instance_id),
- INDEX idx_group_cluster (cluster_id)
+ INDEX idx_group_cluster (cluster_id),
+ INDEX idx_group_domain (domain)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 5. K8s 证书管理
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index efbddcf2c..a5182abe2 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -109,6 +109,9 @@ class InstanceServiceTest {
@Mock
private ResourceOwnershipGuard ownershipGuard;
+ @Mock
+ private
org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryService
nameserverRegistry;
+
@InjectMocks
private InstanceService instanceService;
@@ -133,7 +136,7 @@ class InstanceServiceTest {
@Test
void updateOwnedInstanceStopsBeforeSaveTest() {
InstanceVO existing = InstanceVO.builder().name("instance-a")
- .endpoint("old:9876").vendor(InstanceVendor.APACHE).build();
+
.endpoint("old:9876").vendor(InstanceVendor.APACHE).nameserverId(1L).build();
existing.setId(1L);
InstanceVO request = InstanceVO.builder().endpoint("new:9876").build();
request.setId(1L);
@@ -483,7 +486,7 @@ class InstanceServiceTest {
.name("new-instance")
.endpoint("10.0.1.1:8080")
.type(InstanceType.PROXY_CLUSTER)
- .build();
+ .nameserverId(1L).build();
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation -> {
InstanceVO saved = invocation.getArgument(0);
@@ -569,7 +572,7 @@ class InstanceServiceTest {
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
String atLimit = "n".repeat(512);
InstanceVO accepted =
InstanceVO.builder().name("inst-a").type(InstanceType.PROXY_CLUSTER)
- .endpoint(atLimit).build();
+ .endpoint(atLimit).nameserverId(1L).build();
assertThat(instanceService.createInstance(accepted).getEndpoint()).isEqualTo(atLimit);
@@ -607,7 +610,7 @@ class InstanceServiceTest {
// value fits the column and counting chars would reject it as a 500
from the write instead.
String atLimit =
EMOJI.repeat(InstanceService.MAX_INSTANCE_ENDPOINT_LENGTH);
InstanceVO accepted =
InstanceVO.builder().name("inst-a").type(InstanceType.PROXY_CLUSTER)
- .endpoint(atLimit).build();
+ .endpoint(atLimit).nameserverId(1L).build();
assertThat(instanceService.createInstance(accepted).getEndpoint()).isEqualTo(atLimit);
@@ -625,12 +628,12 @@ class InstanceServiceTest {
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
String atLimit = "r".repeat(255);
InstanceVO accepted =
InstanceVO.builder().name("inst-a").type(InstanceType.PROXY_CLUSTER)
- .endpoint("namesrv:9876").remark(atLimit).build();
+
.endpoint("namesrv:9876").remark(atLimit).nameserverId(1L).build();
assertThat(instanceService.createInstance(accepted).getRemark()).isEqualTo(atLimit);
InstanceVO rejected =
InstanceVO.builder().name("inst-b").type(InstanceType.PROXY_CLUSTER)
- .endpoint("namesrv:9876").remark(atLimit + "r").build();
+ .endpoint("namesrv:9876").remark(atLimit +
"r").nameserverId(1L).build();
assertThatThrownBy(() -> instanceService.createInstance(rejected))
.isInstanceOf(BusinessException.class)
.hasMessage("InstanceVO remark must not exceed 255
characters");
@@ -661,7 +664,7 @@ class InstanceServiceTest {
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
String atLimit = "c".repeat(128);
InstanceVO accepted =
InstanceVO.builder().name("inst-a").type(InstanceType.PROXY_CLUSTER)
- .endpoint("namesrv:9876").adminCredentialRef(atLimit).build();
+
.endpoint("namesrv:9876").adminCredentialRef(atLimit).nameserverId(1L).build();
assertThat(instanceService.createInstance(accepted).getAdminCredentialRef()).isEqualTo(atLimit);
@@ -693,7 +696,7 @@ class InstanceServiceTest {
@Test
void createInstanceShouldTrimEndpointBeforeSaving() {
InstanceVO input =
InstanceVO.builder().name("valid-name").type(InstanceType.PROXY_CLUSTER)
- .endpoint(" namesrv:9876 ").build();
+ .endpoint(" namesrv:9876 ").nameserverId(1L).build();
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
assertThat(instanceService.createInstance(input).getEndpoint()).isEqualTo("namesrv:9876");
@@ -702,7 +705,7 @@ class InstanceServiceTest {
@Test
void createInstanceShouldTrimNameBeforeSaving() {
InstanceVO input = InstanceVO.builder().name(" production
").type(InstanceType.PROXY_CLUSTER)
- .endpoint("namesrv:9876").build();
+ .endpoint("namesrv:9876").nameserverId(1L).build();
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
assertThat(instanceService.createInstance(input).getName()).isEqualTo("production");
@@ -711,7 +714,7 @@ class InstanceServiceTest {
@Test
void
createApacheInstanceShouldTrimAndPersistOnlyAdminCredentialReference() {
InstanceVO input =
InstanceVO.builder().name("production").type(InstanceType.PROXY_CLUSTER)
- .endpoint("namesrv:9876").adminCredentialRef("
production-admin ").build();
+ .endpoint("namesrv:9876").adminCredentialRef("
production-admin ").nameserverId(1L).build();
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
InstanceVO saved = instanceService.createInstance(input);
@@ -837,69 +840,49 @@ class InstanceServiceTest {
}
@Test
- void updateInstanceShouldReleaseUnusedPreviousEndpoint() {
+ void updateInstanceShouldRejectEndpointOutsideTheBoundNameserverTest() {
InstanceVO existing = InstanceVO.builder()
.name("instance")
+ .type(InstanceType.DIRECT)
.endpoint("old-namesrv:9876")
+ .nameserverId(1L)
.build();
existing.setId(1L);
InstanceVO update =
InstanceVO.builder().endpoint("new-namesrv:9876").build();
update.setId(1L);
when(instanceRepository.findById(1L)).thenReturn(Optional.of(existing));
-
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
- when(instanceRepository.findAll()).thenReturn(List.of());
-
- instanceService.updateInstance(update);
-
- verify(adminFactory).release("old-namesrv:9876");
- }
-
- @Test
- void
updateInstanceShouldKeepEndpointClientWhenAnotherInstanceReferencesIt() {
- InstanceVO existing = InstanceVO.builder()
- .name("instance")
- .endpoint("shared-namesrv:9876")
- .build();
- existing.setId(1L);
- InstanceVO shared = InstanceVO.builder()
- .name("shared-instance")
- .endpoint(" shared-namesrv:9876 ")
- .build();
- shared.setId(2L);
- InstanceVO update =
InstanceVO.builder().endpoint("new-namesrv:9876").build();
- update.setId(1L);
-
-
when(instanceRepository.findById(1L)).thenReturn(Optional.of(existing));
-
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
- when(instanceRepository.findAll()).thenReturn(List.of(shared));
-
- instanceService.updateInstance(update);
+ when(nameserverRegistry.addressOf(1L))
+
.thenReturn(org.apache.rocketmq.studio.cluster.nameserver.NamesrvAddrParser.normalize("old-namesrv:9876"));
- verify(adminFactory, never()).release("shared-namesrv:9876");
+ assertThatThrownBy(() -> instanceService.updateInstance(update))
+ .isInstanceOfSatisfying(BusinessException.class, ex ->
assertThat(ex.getCode()).isEqualTo(400));
+ verify(adminFactory, org.mockito.Mockito.never()).release(anyString());
+ verify(instanceRepository,
org.mockito.Mockito.never()).save(any(InstanceVO.class));
}
@Test
- void
updateInstanceShouldKeepEndpointClientWhenAnotherInstanceUsesEquivalentAddressList()
{
+ void
updateInstanceShouldAcceptAnEquivalentRewriteOfTheBoundAddressListTest() {
InstanceVO existing = InstanceVO.builder()
.name("instance")
- .endpoint("namesrv-b:9876;namesrv-a:9876")
+ .type(InstanceType.DIRECT)
+ .endpoint("namesrv-a:9876;namesrv-b:9876")
+ .nameserverId(1L)
.build();
existing.setId(1L);
- InstanceVO shared = InstanceVO.builder()
- .name("shared-instance")
- .endpoint(" namesrv-a:9876, namesrv-b:9876 ")
- .build();
- shared.setId(2L);
- InstanceVO update =
InstanceVO.builder().endpoint("new-namesrv:9876").build();
+ InstanceVO update = InstanceVO.builder().endpoint(" namesrv-a:9876,
namesrv-b:9876 ").build();
update.setId(1L);
when(instanceRepository.findById(1L)).thenReturn(Optional.of(existing));
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
- when(instanceRepository.findAll()).thenReturn(List.of(shared));
+
when(nameserverRegistry.addressOf(1L)).thenReturn(org.apache.rocketmq.studio.cluster.nameserver
+ .NamesrvAddrParser.normalize(" namesrv-a:9876, namesrv-b:9876
"));
- instanceService.updateInstance(update);
+ InstanceVO saved = instanceService.updateInstance(update);
+ assertThat(saved.getEndpoint())
+
.isEqualTo(org.apache.rocketmq.studio.cluster.nameserver.NamesrvAddrParser
+ .normalize(" namesrv-a:9876, namesrv-b:9876 "));
verifyNoInteractions(adminFactory);
}
@@ -1362,7 +1345,7 @@ class InstanceServiceTest {
.name("inst")
.endpoint("10.0.0.1:8080")
.type(InstanceType.PROXY_CLUSTER)
- .build();
+ .nameserverId(1L).build();
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
InstanceVO created = instanceService.createInstance(instance);
@@ -1378,7 +1361,7 @@ class InstanceServiceTest {
.name("local-proxy")
.endpoint("broker-proxy:8080")
.type(InstanceType.PROXY_LOCAL)
- .build();
+ .nameserverId(1L).build();
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
InstanceVO created = instanceService.createInstance(instance);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
index d5f21dba2..4755a19ad 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/MybatisPlusInstanceRepositoryTest.java
@@ -58,6 +58,9 @@ class MybatisPlusInstanceRepositoryTest {
@Mock
private RmqGroupMapper groupMapper;
+ @Mock
+ private
org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryService
nameserverRegistry;
+
@InjectMocks
private MybatisPlusInstanceRepository repository;
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
index 0f0407e58..5a8d5c57b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
@@ -79,6 +79,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
@@ -104,8 +105,9 @@ class ResourceOwnershipGuardTest {
resolver = mock(InstanceResolver.class);
defaultClusters = mock(RocketMQDefaultClusterResolver.class);
guard = new ResourceOwnershipGuard(jdbc, transactions, resolver);
- jdbc.update("INSERT INTO rmq_instance (name, type, endpoint, vendor)
VALUES ('first', 'DIRECT', 'same:9876', 'APACHE')");
- jdbc.update("INSERT INTO rmq_instance (name, type, endpoint, vendor)
VALUES ('second', 'DIRECT', 'same:9876', 'APACHE')");
+ jdbc.update("INSERT INTO rmq_nameserver (name, namesrv_addr, status)
VALUES ('ns-test', 'same:9876', 'healthy')");
+ jdbc.update("INSERT INTO rmq_instance (name, type, endpoint, vendor,
nameserver_id) VALUES ('first', 'DIRECT', 'same:9876', 'APACHE', 1)");
+ jdbc.update("INSERT INTO rmq_instance (name, type, endpoint, vendor,
nameserver_id) VALUES ('second', 'DIRECT', 'same:9876', 'APACHE', 1)");
first = instance("first");
second = instance("second");
}
@@ -184,7 +186,7 @@ class ResourceOwnershipGuardTest {
jdbc.update("DELETE FROM rmq_instance_topic WHERE name =
'orders'");
throw new BusinessException(502, "NameServer failed");
})).isInstanceOf(BusinessException.class);
- assertCode(() -> guard.check(second, topic, true), 409);
+ assertCode(() -> guard.checkSlot(second, topic, null, true), 409);
}
@Test
@@ -198,11 +200,50 @@ class ResourceOwnershipGuardTest {
}
@Test
- void legacyOwnerOnlyAcceptsConfirmedVirtualClusterTest() {
- jdbc.update("INSERT INTO rmq_instance_topic (name, cluster_id) VALUES
('orders', 'cluster')");
- assertCode(() -> guard.check(first, topic, true), 409);
- InstanceVO virtual =
InstanceVO.builder().name("cluster").vendor(InstanceVendor.APACHE).build();
- assertThat(guard.check(virtual, topic, true).instanceId()).isEmpty();
+ void sameNameInDifferentDomainsCoexistsTest() {
+ jdbc.update("INSERT INTO rmq_nameserver (name, namesrv_addr, status)
VALUES ('ns-other', 'other:9876', 'healthy')");
+ jdbc.update("INSERT INTO rmq_instance (name, type, endpoint, vendor,
nameserver_id)"
+ + " VALUES ('third', 'DIRECT', 'other:9876', 'APACHE', 2)");
+ InstanceVO third = instance("third");
+ guard.write(first, topic, "cluster", true, "NORMAL", () -> null);
+ guard.write(third, topic, "cluster", true, "NORMAL", () -> null);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM
rmq_instance_topic", Integer.class)).isEqualTo(2);
+ }
+
+ @Test
+ void sameNameOnDifferentClustersOfOneDomainCoexistsTest() {
+ guard.write(first, topic, "cluster-a", true, "NORMAL", () -> null);
+ guard.write(second, topic, "cluster-b", true, "NORMAL", () -> null);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM
rmq_instance_topic", Integer.class)).isEqualTo(2);
+ assertThat(guard.slots(first, topic)).hasSize(2);
+ }
+
+ @Test
+ void sameSlotSecondInstanceIsRejectedTest() {
+ guard.write(first, topic, "cluster-a", true, "NORMAL", () -> null);
+ assertCode(() -> guard.write(second, topic, "cluster-a", true,
"NORMAL", () -> null), 409);
+ }
+
+ @Test
+ void sameSlotSameInstanceIsAnUpsertTest() {
+ guard.write(first, topic, "cluster-a", true, "NORMAL", () -> null);
+ guard.write(first, topic, "cluster-a", true, "NORMAL", () -> null);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM
rmq_instance_topic", Integer.class)).isEqualTo(1);
+ }
+
+ @Test
+ void releasedSlotCanBeClaimedByAnotherInstanceTest() {
+ guard.write(first, topic, "cluster-a", true, "NORMAL", () -> null);
+ jdbc.update("DELETE FROM rmq_instance_topic WHERE name = 'orders'");
+ guard.write(second, topic, "cluster-a", true, "NORMAL", () -> null);
+ assertThat(jdbc.queryForObject("SELECT instance_id FROM
rmq_instance_topic", String.class)).isEqualTo("second");
+ }
+
+ @Test
+ void foreignOwnerInSameDomainIsRejectedBeforeAnyWriteTest() {
+ jdbc.update("INSERT INTO rmq_instance_topic (name, cluster_id, domain,
instance_id) VALUES ('orders', 'cluster', 'ns:1', 'first')");
+ assertCode(() -> guard.checkSlot(second, topic, null, true), 409);
+ assertThat(guard.checkSlot(first, topic, null,
true).instanceId()).isEqualTo("first");
}
@Test
@@ -210,31 +251,35 @@ class ResourceOwnershipGuardTest {
legacyIndexes();
guard.write(first, topic, "cluster", true, "NORMAL", () -> null);
jdbc.execute("DROP TABLE rmq_instance_ownership_lock");
- var migration = new ResourceOwnershipSchemaMigration(dataSource,
defaultClusters);
+ var migration = new ResourceOwnershipSchemaMigration(dataSource);
migration.afterPropertiesSet();
migration.afterPropertiesSet();
guard.write(first, topic, "cluster", true, "NORMAL", () -> null);
- assertThatThrownBy(() -> jdbc.update("INSERT INTO rmq_instance_topic
(name, instance_id, cluster_id)"
- + " VALUES ('orders', 'second',
'other')")).isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
+ // The slot key still rejects a second owner of the very same (domain,
cluster, name)...
+ assertThatThrownBy(() -> jdbc.update("INSERT INTO rmq_instance_topic
(name, instance_id, cluster_id, domain)"
+ + " VALUES ('orders', 'second', 'cluster',
'ns:1')")).isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM
rmq_instance_topic", Integer.class)).isEqualTo(1);
+ // ...while the same name on another broker cluster of the domain is
exactly what the model allows.
+ jdbc.update("INSERT INTO rmq_instance_topic (name, instance_id,
cluster_id, domain)"
+ + " VALUES ('orders', 'second', 'other', 'ns:1')");
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM
rmq_instance_topic", Integer.class)).isEqualTo(2);
}
@Test
void migrationRejectsDuplicatesWithoutDiscardingAnyRowsTest() {
legacyIndexes();
- jdbc.update("INSERT INTO rmq_instance_group (name, instance_id,
cluster_id) VALUES ('buyers', 'first', 'a')");
- jdbc.update("INSERT INTO rmq_instance_group (name, instance_id,
cluster_id) VALUES ('buyers', 'second', 'b')");
- assertThatThrownBy(() -> new
ResourceOwnershipSchemaMigration(dataSource,
defaultClusters).afterPropertiesSet())
-
.isInstanceOf(IllegalStateException.class).hasMessageContaining("duplicate
name");
+ jdbc.update("INSERT INTO rmq_instance_group (name, instance_id,
cluster_id, domain) VALUES ('buyers', 'first', 'a', 'ns:1')");
+ jdbc.update("INSERT INTO rmq_instance_group (name, instance_id,
cluster_id, domain) VALUES ('buyers', 'second', 'a', 'ns:1')");
+ assertThatThrownBy(() -> new
ResourceOwnershipSchemaMigration(dataSource).afterPropertiesSet())
+
.isInstanceOf(IllegalStateException.class).hasMessageContaining("manual
resolution");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM
rmq_instance_group", Integer.class)).isEqualTo(2);
}
@Test
void migrationRejectsUnknownLegacyOwnerWithoutChangingItTest() {
jdbc.update("INSERT INTO rmq_instance_topic (name, cluster_id) VALUES
('orders', 'unknown')");
- when(defaultClusters.names()).thenReturn(List.of("cluster"));
- assertThatThrownBy(() -> new
ResourceOwnershipSchemaMigration(dataSource,
defaultClusters).afterPropertiesSet())
-
.isInstanceOf(IllegalStateException.class).hasMessageContaining("no trusted
virtual cluster mapping");
+ assertThatThrownBy(() -> new
ResourceOwnershipSchemaMigration(dataSource).afterPropertiesSet())
+
.isInstanceOf(IllegalStateException.class).hasMessageContaining("cannot be
determined");
assertThat(jdbc.queryForObject("SELECT instance_id FROM
rmq_instance_topic", String.class)).isEmpty();
}
@@ -243,21 +288,23 @@ class ResourceOwnershipGuardTest {
var runtime = mock(RuntimeAdminClientResolver.class);
var broker = mock(DefaultMQAdminExt.class);
var admin = apacheAdmin(runtime, broker);
- InstanceVO virtual =
InstanceVO.builder().name("cluster-b").endpoint("same:9876").build();
-
when(resolver.findByIdentifier("cluster-b")).thenReturn(Optional.of(virtual));
+ jdbc.update("INSERT INTO rmq_instance (name, type, endpoint, vendor,
nameserver_id) VALUES ('prod-b', 'DIRECT', 'same:9876', 'APACHE', 1)");
+ InstanceVO registered = instance("prod-b");
+
when(resolver.findByIdentifier("prod-b")).thenReturn(Optional.of(registered));
when(broker.examineBrokerClusterInfo()).thenReturn(twoClusters());
TopicVO request = new TopicVO();
request.setName("orders");
- admin.createTopic("cluster-b", request);
+ request.setClusterId("cluster-b");
+ admin.createTopic("prod-b", request);
- assertThat(guard.check(virtual, topic,
true).clusterId()).isEqualTo("cluster-b");
+ assertThat(guard.checkSlot(registered, topic, null,
true).clusterId()).isEqualTo("cluster-b");
verify(broker).createAndUpdateTopicConfig(eq("10.0.1.1:10911"),
any(TopicConfig.class));
verify(broker,
never()).createAndUpdateTopicConfig(eq("10.0.0.1:10911"),
any(TopicConfig.class));
verify(broker,
never()).createAndUpdateTopicConfig(eq("10.0.1.2:10911"),
any(TopicConfig.class));
}
@Test
- void apacheWritesRejectForeignOwnerBeforeAnyBrokerOrProducerCallTest() {
+ void apacheWritesRejectForeignOwnerBeforeAnyBrokerOrProducerCallTest()
throws Exception {
guard.write(first, topic, "cluster-a", true, "NORMAL", () -> null);
guard.write(first, new Resource(Kind.GROUP, "buyers"), "cluster-a",
true, null, () -> null);
var runtime = mock(RuntimeAdminClientResolver.class);
@@ -285,7 +332,10 @@ class ResourceOwnershipGuardTest {
direct.setClientId("client");
direct.setMsgId("msg");
assertCode(() -> new RocketMQMessageProvider(runtime,
guard).consumeMessageDirectly(direct), 409);
- verifyNoInteractions(runtime, broker);
+ verify(broker, never()).createAndUpdateTopicConfig(anyString(), any());
+ verify(broker,
never()).createAndUpdateSubscriptionGroupConfig(anyString(), any());
+ verify(broker, never()).deleteTopicInBroker(any(), anyString());
+ verify(broker, never()).deleteSubscriptionGroup(anyString(),
anyString(), anyBoolean());
}
@Test
@@ -359,9 +409,8 @@ class ResourceOwnershipGuardTest {
@Test
void migrationRejectsMissingExplicitOwnerWithoutDataLossTest() {
jdbc.update("INSERT INTO rmq_instance_topic (name, instance_id,
cluster_id) VALUES ('orders', 'gone', 'cluster')");
- when(defaultClusters.names()).thenReturn(List.of("cluster"));
- assertThatThrownBy(() -> new
ResourceOwnershipSchemaMigration(dataSource,
defaultClusters).afterPropertiesSet())
-
.isInstanceOf(IllegalStateException.class).hasMessageContaining("does not exist
and has no trusted virtual mapping");
+ assertThatThrownBy(() -> new
ResourceOwnershipSchemaMigration(dataSource).afterPropertiesSet())
+
.isInstanceOf(IllegalStateException.class).hasMessageContaining("cannot be
determined");
assertThat(jdbc.queryForObject("SELECT instance_id FROM
rmq_instance_topic", String.class)).isEqualTo("gone");
}
@@ -381,7 +430,7 @@ class ResourceOwnershipGuardTest {
request.setName("orders");
request.setType(TopicType.NORMAL);
admin.importTopic(" first ", request);
- assertThat(guard.check(first, topic,
true).clusterId()).isEqualTo("cluster-b");
+ assertThat(guard.checkSlot(first, topic, null,
true).clusterId()).isEqualTo("cluster-b");
assertThat(jdbc.queryForObject("SELECT status FROM
rmq_instance_topic", String.class)).isEqualTo("ACTIVE");
request.setReadQueues(16);
assertCode(() -> admin.importTopic("first", request), 409);
@@ -408,7 +457,7 @@ class ResourceOwnershipGuardTest {
request.setName("orders");
request.setType(type);
admin.importTopic("first", request);
- assertThat(guard.check(first, topic,
true).topicType()).isEqualTo(type.name());
+ assertThat(guard.checkSlot(first, topic, null,
true).topicType()).isEqualTo(type.name());
physical.setAttributes(Map.of("message.type", type == TopicType.NORMAL
? "FIFO" : "NORMAL"));
assertCode(() -> admin.importTopic("first", request), 409);
verify(broker, never()).createAndUpdateTopicConfig(anyString(), any());
@@ -426,7 +475,7 @@ class ResourceOwnershipGuardTest {
topology.getBrokerAddrTable().remove("broker-a");
when(broker.examineBrokerClusterInfo()).thenReturn(topology);
org.mockito.Mockito.doAnswer(call -> {
- assertThat(guard.check(first, topic,
true).instanceId()).isEqualTo("first");
+ assertThat(guard.checkSlot(first, topic, null,
true).instanceId()).isEqualTo("first");
throw new BusinessException(502, "Remote already partially
succeeded");
}).when(broker).createAndUpdateTopicConfig(anyString(), any());
TopicVO request = new TopicVO();
@@ -493,29 +542,27 @@ class ResourceOwnershipGuardTest {
assertCode(() -> lite.extendTTL("first", "orders", 120_000L), 502);
assertCode(() -> lite.extendTTL("second", "orders", 120_000L), 409);
verifyNoInteractions(factory);
- assertThat(guard.check(first, topic,
true).instanceId()).isEqualTo("first");
+ assertThat(guard.checkSlot(first, topic, null,
true).instanceId()).isEqualTo("first");
}
@Test
- void virtualClaimAndRegistrationSerializeWithoutChangingOwnershipTest()
throws Exception {
- InstanceVO virtual = InstanceVO.builder().name("virtual").build();
+ void registeredClaimAndRegistrationSerializeWithoutChangingOwnershipTest()
throws Exception {
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
- var write = executor.submit(() -> guard.write(virtual, topic,
"virtual", true, "NORMAL", () -> {
+ var write = executor.submit(() -> guard.write(first, topic,
"cluster", true, "NORMAL", () -> {
entered.countDown();
await(release);
return null;
}));
assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue();
- var register = executor.submit(() -> assertCode(() ->
guard.withInstanceRegistration("virtual", () ->
- jdbc.update("INSERT INTO rmq_instance (name, type,
endpoint) VALUES ('virtual','DIRECT','other:9876')")), 409));
+ var register = executor.submit(() -> assertCode(() ->
guard.withInstanceRegistration("first", () ->
+ jdbc.update("INSERT INTO rmq_instance (name, type,
endpoint, vendor, nameserver_id) VALUES ('dup', 'DIRECT', 'other:9876',
'APACHE', 1)")), 409));
release.countDown();
write.get(10, TimeUnit.SECONDS);
register.get(10, TimeUnit.SECONDS);
}
- assertThat(guard.check(virtual, topic,
true).instanceId()).isEqualTo("virtual");
- assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM rmq_instance
WHERE name = 'virtual'", Integer.class)).isZero();
+ assertThat(guard.checkSlot(first, topic, null,
true).instanceId()).isEqualTo("first");
}
@Test
@@ -539,13 +586,12 @@ class ResourceOwnershipGuardTest {
@Test
void migrationAcceptsTrustedVirtualOwnerWithoutRewritingLegacyDataTest()
throws Exception {
- jdbc.update("INSERT INTO rmq_instance_topic (name, cluster_id) VALUES
('orders', 'cluster')");
- when(defaultClusters.names()).thenReturn(List.of("cluster"));
- var migration = new ResourceOwnershipSchemaMigration(dataSource,
defaultClusters);
+ jdbc.update("INSERT INTO rmq_instance_topic (name, instance_id,
cluster_id) VALUES ('orders', 'first', 'cluster')");
+ var migration = new ResourceOwnershipSchemaMigration(dataSource);
migration.afterPropertiesSet();
migration.afterPropertiesSet();
- assertThat(jdbc.queryForObject("SELECT instance_id FROM
rmq_instance_topic", String.class)).isEmpty();
- assertCode(() -> guard.withInstanceRegistration("cluster", () ->
null), 409);
+ assertThat(jdbc.queryForObject("SELECT domain FROM
rmq_instance_topic", String.class)).isEqualTo("ns:1");
+ assertCode(() -> guard.withInstanceRegistration("first", () -> null),
409);
}
@Test
@@ -556,7 +602,7 @@ class ResourceOwnershipGuardTest {
try (var executor = Executors.newFixedThreadPool(2)) {
java.util.concurrent.Callable<Void> migrate = () -> {
await(start);
- new ResourceOwnershipSchemaMigration(dataSource,
defaultClusters).afterPropertiesSet();
+ new
ResourceOwnershipSchemaMigration(dataSource).afterPropertiesSet();
return null;
};
var a = executor.submit(migrate);
@@ -644,7 +690,7 @@ class ResourceOwnershipGuardTest {
private void legacyIndexes() {
for (String kind : List.of("topic", "group")) {
- jdbc.execute("ALTER TABLE rmq_instance_" + kind + " DROP INDEX
uk_" + kind + "_name");
+ jdbc.execute("ALTER TABLE rmq_instance_" + kind + " DROP INDEX
uk_" + kind + "_domain");
jdbc.execute("CREATE UNIQUE INDEX uk_cluster_instance_" + kind + "
ON rmq_instance_" + kind
+ " (cluster_id, instance_id, name)");
}
@@ -662,7 +708,11 @@ class ResourceOwnershipGuardTest {
private InstanceVO instance(String name) {
InstanceVO instance =
InstanceVO.builder().name(name).vendor(InstanceVendor.APACHE)
- .type(InstanceType.DIRECT).endpoint("same:9876").build();
+ .type(InstanceType.DIRECT)
+ .endpoint(jdbc.queryForObject("SELECT endpoint FROM
rmq_instance WHERE name = ?", String.class, name))
+ .nameserverId(jdbc.queryForObject("SELECT nameserver_id FROM
rmq_instance WHERE name = ?",
+ Long.class, name))
+ .build();
instance.setId(jdbc.queryForObject("SELECT id FROM rmq_instance WHERE
name = ?", Long.class, name));
return instance;
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
index d4d2b51c1..daa624b11 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
@@ -96,7 +96,7 @@ class LiteTopicServiceTest {
var resource = new
ResourceOwnershipGuard.Resource(ResourceOwnershipGuard.Kind.TOPIC, "chat");
when(guard.requireInstance("instance-a")).thenReturn(instance);
when(guard.topicResource(" chat ")).thenReturn(resource);
- when(guard.check(instance, resource, true)).thenThrow(new
BusinessException(409, "Ownership conflict"));
+ when(guard.checkSlot(instance, resource, null, true)).thenThrow(new
BusinessException(409, "Ownership conflict"));
assertThatThrownBy(() -> service.extendTTL(" instance-a ", " chat ",
60_000L))
.isInstanceOfSatisfying(BusinessException.class, ex ->
assertThat(ex.getCode()).isEqualTo(409));
org.mockito.Mockito.verifyNoInteractions(provider);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index 6e31a1c8b..ddea630da 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -713,39 +713,21 @@ class MetadataServiceTest {
}
@Test
- void crossInstanceWritesStopBeforeProviderTest() {
- when(ownershipGuard.check(any(), any(),
org.mockito.ArgumentMatchers.anyBoolean()))
+ void crossInstanceDeletesAndMessageOpsStopBeforeProviderTest() {
+ when(ownershipGuard.checkOwnerInDomain(any(), any(),
org.mockito.ArgumentMatchers.anyBoolean()))
.thenThrow(new BusinessException(409, "Resource belongs to
another instance"));
- TopicVO topic = topic("orders", null, TopicType.NORMAL);
- topic.setInstanceId("instance-a");
ConsumerGroupVO group = new ConsumerGroupVO();
group.setName("buyers");
group.setInstanceId("instance-a");
- assertCode(() -> metadataService.createTopic(topic), 409);
- assertCode(() -> metadataService.updateTopic(topic), 409);
+ // Registration writes resolve ownership inside the provider slot
reservation instead,
+ // so only the delete and message paths pre-check here.
assertCode(() -> metadataService.deleteTopic("instance-a", "orders"),
409);
- assertCode(() -> metadataService.createConsumerGroup(group), 409);
- assertCode(() -> metadataService.updateConsumerGroup(group), 409);
assertCode(() -> metadataService.deleteConsumerGroup("instance-a",
"buyers"), 409);
assertCode(() -> metadataService.resetOffset("instance-a", "buyers",
1L, "orders"), 409);
assertCode(() -> metadataService.skipAccumulated("instance-a",
"buyers", "orders"), 409);
assertCode(() -> metadataService.redeliverMessage("instance-a",
"buyers", "orders", "msg", null), 409);
assertCode(() -> metadataService.sendMessage(SendMessageDTO.builder()
.instanceId("instance-a").topic("orders").body("x").build()),
409);
- var topics = metadataService.importTopics("instance-a",
List.of(topicImportRequest("orders", null)));
- assertThat(topics.getFailed()).isEqualTo(1);
- assertThat(topics.getFailures()).singleElement().satisfies(failure -> {
- assertThat(failure.getName()).isEqualTo("orders");
- assertThat(failure.getIndex()).isZero();
- assertThat(failure.getMessage()).isEqualTo("Resource belongs to
another instance");
- });
- var groups = metadataService.importConsumerGroups("instance-a",
List.of(importRequest("buyers", null)));
- assertThat(groups.getFailed()).isEqualTo(1);
- assertThat(groups.getFailures()).singleElement().satisfies(failure -> {
- assertThat(failure.getName()).isEqualTo("buyers");
- assertThat(failure.getIndex()).isZero();
- assertThat(failure.getMessage()).isEqualTo("Resource belongs to
another instance");
- });
verifyNoInteractions(apacheProvider, cloudProvider, adminClient,
runtimeAdminClientResolver, messageService);
}
@@ -1211,7 +1193,7 @@ class MetadataServiceTest {
@Test
void previewResetOffsetShouldRejectForeignGroupBeforeProviderAccess() {
doThrow(new BusinessException(409, "Resource belongs to another
instance"))
- .when(ownershipGuard).check(any(), eq(new
ResourceOwnershipGuard.Resource(
+ .when(ownershipGuard).checkOwnerInDomain(any(), eq(new
ResourceOwnershipGuard.Resource(
ResourceOwnershipGuard.Kind.GROUP, "cg-orders")),
eq(true));
assertThatThrownBy(() -> metadataService.previewResetOffset(
@@ -1224,10 +1206,10 @@ class MetadataServiceTest {
@Test
void previewResetOffsetShouldRejectForeignTopicBeforeProviderAccess() {
- when(ownershipGuard.check(any(), eq(new
ResourceOwnershipGuard.Resource(
+ when(ownershipGuard.checkOwnerInDomain(any(), eq(new
ResourceOwnershipGuard.Resource(
ResourceOwnershipGuard.Kind.GROUP, "cg-orders")),
eq(true))).thenReturn(null);
doThrow(new BusinessException(409, "Resource belongs to another
instance"))
- .when(ownershipGuard).check(any(), eq(new
ResourceOwnershipGuard.Resource(
+ .when(ownershipGuard).checkOwnerInDomain(any(), eq(new
ResourceOwnershipGuard.Resource(
ResourceOwnershipGuard.Kind.TOPIC, "orders")),
eq(true));
assertThatThrownBy(() -> metadataService.previewResetOffset(
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
index a59217b0b..6a1fd5947 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
@@ -155,19 +155,35 @@ class RocketMQAdminClientImplTest {
return instance;
});
lenient().when(ownershipGuard.topicResource(any())).thenCallRealMethod();
- lenient().when(ownershipGuard.check(any(), any(),
anyBoolean())).thenAnswer(call -> {
+ lenient().when(ownershipGuard.checkOwnerInDomain(any(), any(),
anyBoolean())).thenAnswer(call -> {
if (!call.<Boolean>getArgument(2)) {
return null;
}
var resource =
call.<org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource>getArgument(1);
return new
org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Ownership(
1L, resource.name(),
call.<org.apache.rocketmq.studio.instance.InstanceVO>getArgument(0).getName(),
- "cluster-1", null);
+ "cluster-1", null, "ns:1");
+ });
+ lenient().when(ownershipGuard.checkSlot(any(), any(), any(),
anyBoolean())).thenAnswer(call -> {
+ if (!call.<Boolean>getArgument(3)) {
+ return null;
+ }
+ var resource =
call.<org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource>getArgument(1);
+ return new
org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Ownership(
+ 1L, resource.name(),
call.<org.apache.rocketmq.studio.instance.InstanceVO>getArgument(0).getName(),
+ "cluster-1", null, "ns:1");
});
lenient().when(ownershipGuard.write(any(), any(), any(), anyBoolean(),
any(), any())).thenAnswer(call ->
call.<java.util.function.Supplier<Object>>getArgument(5).get());
lenient().when(ownershipGuard.withOwned(any(), any(),
any())).thenAnswer(call ->
call.<java.util.function.Supplier<Object>>getArgument(2).get());
+ lenient().when(ownershipGuard.domainOf(any())).thenReturn("ns:1");
+ lenient().when(ownershipGuard.slots(any(), any())).thenAnswer(call -> {
+ var resource =
call.<org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource>getArgument(1);
+ return java.util.List.of(new
org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Ownership(
+ 1L, resource.name(),
call.<org.apache.rocketmq.studio.instance.InstanceVO>getArgument(0).getName(),
+ "cluster-1", null, "ns:1"));
+ });
lenient().when(runtimeAdminClientResolver.execute(anyString(),
any())).thenAnswer(call ->
call.<MqAdminExtFactory.AdminAction<Object>>getArgument(1).apply(adminExt));
lenient().when(runtimeAdminClientResolver.resolveEndpoint(anyString())).thenReturn("10.0.0.1:9876");
@@ -749,7 +765,8 @@ class RocketMQAdminClientImplTest {
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(topicMapper, times(2)).selectOne(captor.capture());
for (LambdaQueryWrapper<RmqTopic> wrapper : captor.getAllValues()) {
-
assertThat(wrapper.getSqlSegment()).contains("name").doesNotContain("cluster_id",
"instance_id");
+ assertThat(wrapper.getSqlSegment()).contains("name", "domain",
"cluster_id")
+ .doesNotContain("instance_id");
}
}
@@ -858,8 +875,9 @@ class RocketMQAdminClientImplTest {
ArgumentCaptor<LambdaQueryWrapper<RmqTopic>> queries =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(topicMapper, times(create ? 2 :
1)).selectOne(queries.capture());
for (LambdaQueryWrapper<RmqTopic> query : queries.getAllValues()) {
-
assertThat(query.getSqlSegment()).contains("name").doesNotContain("instance_id",
"cluster_id");
-
assertThat(query.getParamNameValuePairs().values()).containsExactly("orders");
+ assertThat(query.getSqlSegment()).contains("name", "domain",
"cluster_id")
+ .doesNotContain("instance_id");
+
assertThat(query.getParamNameValuePairs().values()).contains("orders", "ns:1");
}
if (create) {
ArgumentCaptor<RmqTopic> saved =
ArgumentCaptor.forClass(RmqTopic.class);
@@ -1232,7 +1250,8 @@ class RocketMQAdminClientImplTest {
ArgumentCaptor<LambdaQueryWrapper<RmqTopic>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(topicMapper).delete(captor.capture());
-
assertThat(captor.getValue().getSqlSegment()).contains("name").doesNotContain("cluster_id",
"instance_id");
+ assertThat(captor.getValue().getSqlSegment()).contains("name",
"domain")
+ .doesNotContain("instance_id", "cluster_id");
verify(selectedAdmin).deleteTopicInBroker(Set.of("10.0.0.1:10911"),
"orders");
verify(selectedAdmin).deleteTopicInNameServer(Set.of("10.0.0.2:9876"),
"cluster-1", "orders");
}
@@ -1287,7 +1306,8 @@ class RocketMQAdminClientImplTest {
verify(adminExt,
never()).createAndUpdateSubscriptionGroupConfig(anyString(), any());
ArgumentCaptor<LambdaQueryWrapper<RmqGroup>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(groupMapper).selectOne(captor.capture());
-
assertThat(captor.getValue().getSqlSegment()).contains("name").doesNotContain("cluster_id",
"instance_id");
+ assertThat(captor.getValue().getSqlSegment()).contains("name",
"domain", "cluster_id")
+ .doesNotContain("instance_id");
}
@ParameterizedTest
@@ -1662,7 +1682,8 @@ class RocketMQAdminClientImplTest {
verify(adminExt, never()).deleteSubscriptionGroup(anyString(),
anyString(), org.mockito.ArgumentMatchers.anyBoolean());
ArgumentCaptor<LambdaQueryWrapper<RmqGroup>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(groupMapper).delete(captor.capture());
-
assertThat(captor.getValue().getSqlSegment()).contains("name").doesNotContain("cluster_id",
"instance_id");
+ assertThat(captor.getValue().getSqlSegment()).contains("name",
"domain", "cluster_id")
+ .doesNotContain("instance_id");
}
@Test
@@ -2010,7 +2031,7 @@ class RocketMQAdminClientImplTest {
String destination = targetTopic == null ? "%RETRY%group-a" :
targetTopic;
assertThat(sent.getTopic()).isEqualTo(destination);
var destinationResource = ownershipGuard.topicResource(destination);
- verify(ownershipGuard, org.mockito.Mockito.atLeastOnce()).check(any(),
eq(destinationResource), eq(true));
+ verify(ownershipGuard,
org.mockito.Mockito.atLeastOnce()).checkOwnerInDomain(any(),
eq(destinationResource), eq(true));
MessageExt delivered = new MessageExt();
delivered.setTopic(sent.getTopic());
delivered.setBody(sent.getBody());
@@ -2098,7 +2119,7 @@ class RocketMQAdminClientImplTest {
.body("payload").properties(Map.of(MessageConst.PROPERTY_RETRY_TOPIC,
"orders")).build();
var logicalTopic = new
org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource(
org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Kind.TOPIC,
"orders");
- when(ownershipGuard.check(any(), eq(logicalTopic), eq(true)))
+ when(ownershipGuard.checkOwnerInDomain(any(), eq(logicalTopic),
eq(true)))
.thenThrow(new
org.apache.rocketmq.studio.common.exception.BusinessException(409, "Topic scope
mismatch"));
assertThatThrownBy(() ->
redeliveryService().redeliverMessage("instance-a", "group-a", source, null))
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
index 3fe083ebb..b3b644ef2 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProviderTest.java
@@ -93,8 +93,9 @@ class RocketMQLiteTopicProviderTest {
when(guard.requireInstance(any())).thenAnswer(call ->
InstanceVO.builder()
.name(ResourceOwnershipGuard.requireText(call.getArgument(0),
"instanceId")).build());
when(guard.topicResource(anyString())).thenCallRealMethod();
- when(guard.check(any(), any(), eq(true))).thenReturn(new
ResourceOwnershipGuard.Ownership(
- 1L, PARENT, "cluster-a", "cluster-a", "LITE"));
+ when(guard.checkSlot(any(), any(),
org.mockito.ArgumentMatchers.isNull(), eq(true)))
+ .thenReturn(new ResourceOwnershipGuard.Ownership(
+ 1L, PARENT, "cluster-a", "cluster-a", "LITE", "ns:1"));
when(guard.withOwned(any(), any(), any())).thenAnswer(call ->
((java.util.function.Supplier<?>) call.getArgument(2)).get());
doAnswer(call -> ((MqAdminExtFactory.AdminAction<?>)
call.getArgument(1)).apply(admin))
@@ -377,7 +378,7 @@ class RocketMQLiteTopicProviderTest {
.isInstanceOfSatisfying(BusinessException.class, ex ->
assertThat(ex.getCode()).isEqualTo(400));
assertThatThrownBy(() -> provider.extendTTL(" ", PARENT, 60_000L))
.isInstanceOfSatisfying(BusinessException.class, ex ->
assertThat(ex.getCode()).isEqualTo(400));
- when(guard.check(any(), any(), eq(true))).thenThrow(new
BusinessException(409, "Ownership conflict"));
+ when(guard.checkSlot(any(), any(),
org.mockito.ArgumentMatchers.isNull(), eq(true))).thenThrow(new
BusinessException(409, "Ownership conflict"));
assertThatThrownBy(() -> provider.extendTTL("cluster-a", PARENT,
60_000L))
.isInstanceOfSatisfying(BusinessException.class, ex ->
assertThat(ex.getCode()).isEqualTo(409));
org.mockito.Mockito.verifyNoInteractions(runtime, admin);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
index ff4f09fb4..7fc1406c5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
@@ -213,7 +213,7 @@ class RocketMQMessageProviderTest {
var instance =
org.apache.rocketmq.studio.instance.InstanceVO.builder().name("cluster-a").build();
when(ownershipGuard.requireInstance("instance-a")).thenReturn(instance);
when(ownershipGuard.topicResource("orders")).thenCallRealMethod();
- when(ownershipGuard.check(any(), any(), eq(true)))
+ when(ownershipGuard.checkOwnerInDomain(any(), any(), eq(true)))
.thenThrow(new BusinessException(409, "Resource belongs to
another instance"));
assertDirectConflict();
verify(runtimeAdminClientResolver, never()).execute(anyString(),
any());
@@ -229,9 +229,9 @@ class RocketMQMessageProviderTest {
var instance =
org.apache.rocketmq.studio.instance.InstanceVO.builder().name("cluster-a").build();
when(ownershipGuard.requireInstance("instance-a")).thenReturn(instance);
when(ownershipGuard.topicResource("orders")).thenCallRealMethod();
- when(ownershipGuard.check(any(), any(), eq(true))).thenReturn(
+ when(ownershipGuard.checkOwnerInDomain(any(), any(),
eq(true))).thenReturn(
new
org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Ownership(
- 1L, "orders", "cluster-a", "cluster-a", "NORMAL"));
+ 1L, "orders", "cluster-a", "cluster-a", "NORMAL",
"ns:1"));
when(ownershipGuard.withOwned(any(), any(), any())).thenAnswer(call ->
call.<java.util.function.Supplier<Object>>getArgument(2).get());
ClusterInfo topology = new ClusterInfo();