You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/09/11 03:43:55 UTC
(rocketmq) branch develop updated: [ISSUE #10825] feat(proxy): Implement the Proxy Admin gRPC management interface (#10826)
This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/rocketmq.git
The following commit(s) were added to refs/heads/develop by this push:
new d38f81e787 [ISSUE #10825] feat(proxy): Implement the Proxy Admin gRPC management interface (#10826)
d38f81e787 is described below
commit d38f81e787c2bb3d05a601192a8014e771b829a4
Author: zhaohai <33...@users.noreply.github.com>
AuthorDate: Fri Sep 11 11:43:49 2026 +0800
[ISSUE #10825] feat(proxy): Implement the Proxy Admin gRPC management interface (#10826)
Implement the control-plane admin capability as a gRPC service
served from the proxy process (NOT connecting to broker remoting/grpc
directly), per the rocketmq-apis admin.proto contract.
---------
Co-authored-by: lizhimins <70...@qq.com>
---
.bazelignore | 18 +
.github/workflows/bazel.yml | 2 +
.github/workflows/codeql_analysis.yml | 18 +-
.github/workflows/coverage.yml | 2 +
.github/workflows/integration-test.yml | 2 +
.github/workflows/maven.yaml | 2 +
.gitmodules | 21 +
WORKSPACE | 88 +-
auth/BUILD.bazel | 4 +-
bazel/rocketmq_apis.BUILD | 33 +
.../rocketmq/common/constant/GrpcConstants.java | 8 +
docs/proxy-admin-zh.md | 240 +++
docs/proxy-admin.md | 370 +++++
pom.xml | 6 +-
proxy/BUILD.bazel | 4 +-
.../org/apache/rocketmq/proxy/ProxyStartup.java | 48 +-
.../apache/rocketmq/proxy/config/ProxyConfig.java | 59 +
.../rocketmq/proxy/grpc/GrpcServerBuilder.java | 43 +-
.../proxy/grpc/admin/AdminModelConverter.java | 372 +++++
.../grpc/admin/ProxyAdminAuthInterceptor.java | 277 ++++
.../proxy/grpc/admin/ProxyAdminForwarder.java | 356 ++++
.../proxy/grpc/admin/ProxyAdminGrpcService.java | 1735 ++++++++++++++++++++
.../grpc/v2/DefaultGrpcMessagingActivity.java | 8 +
.../proxy/grpc/v2/GrpcMessagingApplication.java | 4 +
.../proxy/grpc/v2/channel/GrpcChannelManager.java | 5 +
.../proxy/grpc/v2/channel/GrpcClientChannel.java | 16 +
.../proxy/grpc/v2/common/GrpcConverter.java | 13 +-
.../proxy/processor/DefaultMessagingProcessor.java | 4 +
.../proxy/service/ClusterServiceManager.java | 1 +
.../proxy/service/LocalServiceManager.java | 1 +
.../rocketmq/proxy/service/admin/AdminService.java | 90 +-
.../proxy/service/admin/DefaultAdminService.java | 229 ++-
.../service/relay/ClusterProxyRelayService.java | 47 +-
.../proxy/service/relay/ProxyRelayRequest.java | 118 ++
.../proxy/grpc/GrpcServerInterceptorOrderTest.java | 102 ++
.../proxy/grpc/admin/AdminModelConverterTest.java | 533 ++++++
.../grpc/admin/ProxyAdminAuthInterceptorTest.java | 269 +++
.../grpc/admin/ProxyAdminGrpcServiceTest.java | 1495 +++++++++++++++++
.../grpc/v2/channel/GrpcClientChannelTest.java | 73 +
.../service/admin/DefaultAdminServiceTest.java | 386 ++++-
rocketmq-apis | 1 +
rocketmq-proto/BUILD.bazel | 92 ++
rocketmq-proto/pom.xml | 143 ++
test/BUILD.bazel | 2 +-
44 files changed, 7238 insertions(+), 102 deletions(-)
diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000000..17dc28aa5b
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+rocketmq-apis
diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml
index a19bcfc7d5..b93d21b647 100644
--- a/.github/workflows/bazel.yml
+++ b/.github/workflows/bazel.yml
@@ -21,6 +21,8 @@ jobs:
os: [ubuntu-latest]
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: true
- name: Cache Bazel repository
uses: actions/cache@v4
with:
diff --git a/.github/workflows/codeql_analysis.yml b/.github/workflows/codeql_analysis.yml
index fb6ea62a0f..d310d187ac 100644
--- a/.github/workflows/codeql_analysis.yml
+++ b/.github/workflows/codeql_analysis.yml
@@ -18,6 +18,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
+ with:
+ submodules: true
- name: Cache Maven packages
uses: actions/cache@v3
with:
@@ -26,11 +28,19 @@ jobs:
restore-keys: |
${{ runner.os }}-maven-
# Initializes the CodeQL tools for scanning.
+ - name: Set up JDK 8
+ uses: actions/setup-java@v4
+ with:
+ java-version: 8
+ distribution: "corretto"
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: java
- - name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ # Explicit build instead of autobuild: rocketmq-proto is generated from
+ # the rocketmq-apis submodule at compile time, which autobuild cannot
+ # handle reliably.
+ - name: Build with Maven
+ run: mvn -B compile -DskipTests -Dmaven.javadoc.skip=true
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 28d874a354..517a1b3e87 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -14,6 +14,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
+ with:
+ submodules: true
- name: Set up JDK 8
uses: actions/setup-java@v4
with:
diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml
index 6a28111d0b..ad78075e03 100644
--- a/.github/workflows/integration-test.yml
+++ b/.github/workflows/integration-test.yml
@@ -28,6 +28,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
+ with:
+ submodules: true
- name: Set up JDK ${{ matrix.jdk }}
uses: actions/setup-java@v4
diff --git a/.github/workflows/maven.yaml b/.github/workflows/maven.yaml
index 15584e2cd1..629b4a245b 100644
--- a/.github/workflows/maven.yaml
+++ b/.github/workflows/maven.yaml
@@ -22,6 +22,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
+ with:
+ submodules: true
- name: Set up JDK ${{ matrix.jdk }}
uses: actions/setup-java@v4
with:
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000..9d5374f16e
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,21 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[submodule "rocketmq-apis"]
+ path = rocketmq-apis
+ url = https://github.com/apache/rocketmq-apis.git
+ branch = main
diff --git a/WORKSPACE b/WORKSPACE
index 4287bddded..96b1560709 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
RULES_JVM_EXTERNAL_TAG = "4.2"
@@ -71,9 +71,9 @@ maven_install(
"org.bouncycastle:bcpkix-jdk18on:1.83",
"com.google.code.gson:gson:2.9.0",
"com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:1.4.2",
- "org.apache.rocketmq:rocketmq-proto:2.1.2",
- "com.google.protobuf:protobuf-java:3.20.1",
- "com.google.protobuf:protobuf-java-util:3.20.1",
+ "com.google.protobuf:protobuf-java:3.24.4",
+ "com.google.protobuf:protobuf-java-util:3.24.4",
+ "javax.annotation:javax.annotation-api:1.3.2",
"com.conversantmedia:disruptor:1.2.10",
"org.apache.tomcat:annotations-api:6.0.53",
"com.google.code.findbugs:jsr305:3.0.2",
@@ -82,13 +82,13 @@ maven_install(
"org.openjdk.jmh:jmh-core:1.19",
"org.openjdk.jmh:jmh-generator-annprocess:1.19",
"com.github.ben-manes.caffeine:caffeine:2.9.3",
- "io.grpc:grpc-services:1.47.0",
- "io.grpc:grpc-netty-shaded:1.47.0",
- "io.grpc:grpc-context:1.47.0",
- "io.grpc:grpc-stub:1.47.0",
- "io.grpc:grpc-api:1.47.0",
- "io.grpc:grpc-protobuf:1.47.0",
- "io.grpc:grpc-testing:1.47.0",
+ "io.grpc:grpc-services:1.50.0",
+ "io.grpc:grpc-netty-shaded:1.50.0",
+ "io.grpc:grpc-context:1.50.0",
+ "io.grpc:grpc-stub:1.50.0",
+ "io.grpc:grpc-api:1.50.0",
+ "io.grpc:grpc-protobuf:1.50.0",
+ "io.grpc:grpc-testing:1.50.0",
"org.springframework:spring-core:5.3.27",
"io.opentelemetry:opentelemetry-exporter-otlp:1.47.0",
"io.opentelemetry:opentelemetry-exporter-prometheus:1.47.0-alpha",
@@ -125,6 +125,72 @@ maven_install(
],
)
+# Well-known type protos (google/protobuf/{timestamp,duration}.proto). The
+# standalone protoc executable does not bundle them, so they have to be placed on
+# the include path explicitly.
+http_archive(
+ name = "com_google_protobuf_wkt",
+ build_file_content = """
+filegroup(
+ name = "wkt_protos",
+ srcs = glob(["*.proto"]),
+ visibility = ["//visibility:public"],
+)
+""",
+ sha256 = "616bb3536ac1fff3fb1a141450fa28b875e985712170ea7f1bfe5e5fc41e2cd8",
+ strip_prefix = "protobuf-24.4/src/google/protobuf",
+ urls = ["https://github.com/protocolbuffers/protobuf/archive/refs/tags/v24.4.tar.gz"],
+)
+
+# protoc / grpc codegen binaries. rules_jvm_external can only resolve jar
+# artifacts, so the prebuilt executables are fetched directly. Using prebuilt
+# binaries avoids the grpc-java Bazel toolchain, which would build protoc and
+# the grpc plugin from C++ sources.
+http_file(
+ name = "com_google_protobuf_protoc_linux_x86_64",
+ downloaded_file_path = "protoc",
+ executable = True,
+ sha256 = "59a70515db36977cf29ab09323b469dca51dd6572a5e08a731d741f22eef2b6c",
+ urls = ["https://repo1.maven.org/maven2/com/google/protobuf/protoc/3.24.4/protoc-3.24.4-linux-x86_64.exe"],
+)
+
+http_file(
+ name = "com_google_protobuf_protoc_osx_x86_64",
+ downloaded_file_path = "protoc",
+ executable = True,
+ sha256 = "8689519587d41b7af9e3b3f2e8c3d1335a23315857d157f61c26c10a1c61a9fa",
+ urls = ["https://repo1.maven.org/maven2/com/google/protobuf/protoc/3.24.4/protoc-3.24.4-osx-x86_64.exe"],
+)
+
+http_file(
+ name = "io_grpc_protoc_gen_grpc_java_linux_x86_64",
+ downloaded_file_path = "protoc-gen-grpc-java",
+ executable = True,
+ sha256 = "52dcbe738d3c920d7744780c67417309fe4ed990b380e9d1cf073c0654656191",
+ urls = ["https://repo1.maven.org/maven2/io/grpc/protoc-gen-grpc-java/1.53.0/protoc-gen-grpc-java-1.53.0-linux-x86_64.exe"],
+)
+
+http_file(
+ name = "io_grpc_protoc_gen_grpc_java_osx_x86_64",
+ downloaded_file_path = "protoc-gen-grpc-java",
+ executable = True,
+ sha256 = "97da9c1a408fb23391853273272a9c04f1b8ba6a2ce3c18ff95eab9cc91f0388",
+ urls = ["https://repo1.maven.org/maven2/io/grpc/protoc-gen-grpc-java/1.53.0/protoc-gen-grpc-java-1.53.0-osx-x86_64.exe"],
+)
+
+# The rocketmq-apis git submodule (apache main), which supplies
+# apache/rocketmq/v2/*.proto including the admin.proto.
+#
+# The submodule ships its own BUILD files, but they pull in toolchains this
+# workspace does not declare (graknlabs_bazel_distribution, googleapis), so the
+# directory is listed in .bazelignore (keeps `bazel build //...` from trying to
+# build it) and is surfaced here through a minimal build file instead.
+new_local_repository(
+ name = "rocketmq_apis",
+ build_file = "//bazel:rocketmq_apis.BUILD",
+ path = "rocketmq-apis",
+)
+
http_archive(
name = "io_buildbuddy_buildbuddy_toolchain",
sha256 = "b12273608db627eb14051eb75f8a2134590172cd69392086d392e25f3954ea6e",
diff --git a/auth/BUILD.bazel b/auth/BUILD.bazel
index da4962e452..ba3d8ad39f 100644
--- a/auth/BUILD.bazel
+++ b/auth/BUILD.bazel
@@ -28,7 +28,7 @@ java_library(
"@maven//:org_apache_commons_commons_lang3",
"@maven//:commons_collections_commons_collections",
"@maven//:com_alibaba_fastjson2_fastjson2",
- "@maven//:org_apache_rocketmq_rocketmq_proto",
+ "//rocketmq-proto:rocketmq-proto",
"@maven//:org_slf4j_slf4j_api",
"@maven//:com_github_ben_manes_caffeine_caffeine",
"@maven//:io_grpc_grpc_api",
@@ -55,7 +55,7 @@ java_library(
"@maven//:org_apache_commons_commons_lang3",
"@maven//:commons_collections_commons_collections",
"@maven//:com_alibaba_fastjson2_fastjson2",
- "@maven//:org_apache_rocketmq_rocketmq_proto",
+ "//rocketmq-proto:rocketmq-proto",
"@maven//:org_slf4j_slf4j_api",
"@maven//:com_github_ben_manes_caffeine_caffeine",
"@maven//:io_grpc_grpc_api",
diff --git a/bazel/rocketmq_apis.BUILD b/bazel/rocketmq_apis.BUILD
new file mode 100644
index 0000000000..ce0c27b9df
--- /dev/null
+++ b/bazel/rocketmq_apis.BUILD
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Build file for the rocketmq-apis git submodule.
+#
+# The submodule ships its own BUILD files (root + java/ + cpp/), but those pull
+# in toolchains this workspace does not declare (graknlabs_bazel_distribution,
+# googleapis). Rather than dragging all of that in, the directory is listed in
+# .bazelignore (so `bazel build //...` never tries to build it) and is exposed to
+# this workspace as the external repository @rocketmq_apis through a minimal
+# build file instead.
+#
+# The proto set is a glob over the whole v2 directory, so admin.proto is
+# included alongside definition.proto and service.proto.
+filegroup(
+ name = "v2_protos",
+ srcs = glob(["apache/rocketmq/v2/*.proto"]),
+ visibility = ["//visibility:public"],
+)
diff --git a/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java b/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java
index 96293e72ec..9fc4bcf8c6 100644
--- a/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java
+++ b/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java
@@ -73,4 +73,12 @@ public class GrpcConstants {
public static final Metadata.Key<String> CHANNEL_ID
= Metadata.Key.of("x-mq-channel-id", Metadata.ASCII_STRING_MARSHALLER);
+
+ /**
+ * Loop-prevention marker for proxy admin RPCs: set to {@code "true"} by a proxy that forwards
+ * an admin call to the peer proxy owning the target client, so the peer never forwards it
+ * again.
+ */
+ public static final Metadata.Key<String> ADMIN_FORWARDED
+ = Metadata.Key.of("x-mq-admin-forwarded", Metadata.ASCII_STRING_MARSHALLER);
}
diff --git a/docs/proxy-admin-zh.md b/docs/proxy-admin-zh.md
new file mode 100644
index 0000000000..8656fb40cc
--- /dev/null
+++ b/docs/proxy-admin-zh.md
@@ -0,0 +1,240 @@
+# Proxy Admin 管理接口
+
+> 英文版见 `docs/proxy-admin.md`。
+
+## 1. 动机
+
+RocketMQ 5.0 把客户端访问放到了无状态的 Proxy 后面,但运维侧仍然通过 broker 侧的结构(broker 上的 `ConsumerManager`、Remoting 时代的 admin 命令)来观测客户端。连到 Proxy 的 gRPC 客户端对这些工具不可见:控制面无法回答"哪些 SDK 客户端在线、它们订阅了什么、是否健康",只能依赖间接的指标推断。
+
+控制面 dashboard 需要一个标准的服务端接口来读取完整的 gRPC 客户端数据。本次改动在 Proxy 上实现这个接口。
+
+## 2. 契约选择:上游 `Admin` service
+
+gRPC 契约是**上游 `Admin` service**,定义在 `apache/rocketmq-apis` 仓库(main 分支,作为 git submodule 引入,见 §6)的 `apache/rocketmq/v2/admin.proto` 中。本实现刻意**不**在协议层引入自定义的 `ProxyAdminService`:控制面 RPC 接口位于 dashboard 和多语言 SDK 已经在消费的、经过社区评审的版本化 proto 中,并与上游演进保持增量兼容。
+
+proxy 实现 `AdminGrpc.AdminImplBase`(全部 16 个 RPC),并把它绑定在一个独立的 admin gRPC server 上。
+
+## 3. 目标
+
+1. 在 Proxy 上提供一个独立、专用的 gRPC Admin server,与数据面 `MessagingService` 隔离。
+2. 来自 rocketmq-apis main 的稳定、向后兼容契约(`Admin` service,`apache/rocketmq/v2/admin.proto`)。
+3. 通过专用的 `proxy.admin.*` ACL 2.0 资源实现一等公民的鉴权,区分只读与高权限动作。
+4. 集群级语义但不把拓扑泄漏进协议:每个 proxy 都暴露连接/订阅数据的集群可见视图,并把客户端定向 RPC 转发到持有该客户端的 proxy。
+
+## 4. 设计决策
+
+### D1 — 服务放置
+
+上游 `Admin` service 绑定在自己的 gRPC server/端口(`grpcAdminServerPort`,默认 8088)上,有自己的拦截器链,与数据面(8081)分离。整个接口由全局 kill switch `grpcAdminServerEnable` 门控,**默认为 false**:admin server 是 opt-in 的,除非显式启用否则不会启动。
+
+理由:控制面流量绝不能与数据面竞争,且 admin 端口可以只对运维网络开放防火墙。admin server 刻意不暴露 channelz 或 proto reflection。它复用数据面的 `GrpcChannelManager` / `GrpcClientSettingsManager`,使在线客户端对 admin 查询可见。本特性引入的运行时线程池均归实例所有并接入 Proxy 生命周期:admin timeout scheduler 随 `ProxyAdminGrpcService` 关闭,blocking gateway executor 随 `AdminService` 关闭。
+
+### D2 — 鉴权:专用 `proxy.admin.*` 资源
+
+凭据通过标准 gRPC `Authorization` metadata 传入(与数据面相同的方案,ACL 2.0 签名)。认证以拦截器方式执行,顺序排在标准链路的 `HeaderInterceptor` **之后**(因 gRPC 按注册的逆序调用拦截器,故注册在其之前)。这个顺序是必需的:`HeaderInterceptor` 会把入站的 `x-mq-channel-id` 替换为从传输层派生的 channel id,而认证结果按 channel id 缓存,若在客户端自带的值上做认证,一条连接的请求就能复用另一条连接的缓存结果、跳过签名校验。
+
+每个 RPC 映射到一个资源 + 一个动作:
+
+| 资源 | RPC | 动作 |
+|---|---|---|
+| `proxy.admin.client` | ListSubscription, ListConsumerConnection (List); DescribeSubscription, DescribeGroupAccumulation, GetConsumerRunningInfo, QueryTimeSpan (Get) | List / Get |
+| `proxy.admin.config` | ChangeLogLevel | Update |
+| `proxy.admin.connection` | PrintThreadStackTrace, VerifyMessage | Update(高权限)|
+| `proxy.admin.route` | GetTopicRoute | Get |
+| `proxy.admin.ops` | GetProxyRuntimeStats, DescribeTopicStatus, QueryMessage (Get); ResetGroupOffset (Update); DeleteSubscription (Delete); AdminSendMessage (Pub) | Get / Update / Delete / Pub |
+
+建模说明:ACL 2.0 的资源类型是 cluster/namespace/topic/group。admin 资源建模为 CLUSTER 类型的字面量,使用保留名称(资源 key `cluster:proxy.admin.<module>`),从而在不与真实集群名冲突、不改动鉴权核心的前提下实现精确的最小权限匹配。
+
+模式:
+- 集群鉴权关闭、`grpcAdminServerAuthEnable=false` → 开放接口(与数据面语义相同);
+- 集群鉴权开启 → 针对每方法 `proxy.admin.*` 资源的标准 authenticate + authorize 流程;
+- `grpcAdminServerAuthEnable=true` → fail-closed。admin 接口必须既能认证调用方**又能**强制每方法 ACL,因此该模式要求集群级的认证**和**授权开关都开启。任一关闭时所有请求都被拒绝(认证关 → `UNAUTHENTICATED`; 授权关 → `FAILED_PRECONDITION`),而不是在没有真正权限校验的情况下放行。这是刻意为之:ACL 2.0 评估器本身受集群授权开关门控,若在授权关闭时服务请求,会静默放行每个调用,包括破坏性的那些。
+
+审计:每个被服务的 RPC 都会向鉴权审计 logger 写入 `[PROXY-ADMIN-AUDIT] subject/method/resource/action/sourceIp`(与 ACL 2.0 引擎自身的审计日志一起,满足 Console 用户 + AK + 资源 + 操作的审计四元组要求)。
+
+### D3 — 多 proxy 语义:集群可见,协议不含拓扑
+
+一个 gRPC 客户端同一时刻只连到一个 proxy,但 dashboard 仍需从任意单个 admin 端点看到整个集群。两个机制在不给协议加 scope/peering 字段的前提下做到这点:
+
+- **连接 / 订阅数据是集群可见的。** 列表类 RPC(`ListConsumerConnection`、`ListSubscription`、`DescribeSubscription`)把 broker 侧的 `ConsumerConnection` 视图(覆盖 remoting 客户端,以及被 `HeartbeatSyncer` 在 proxy 间同步的 gRPC 客户端)与本 proxy 自己的 `ConsumerManager`(覆盖本地接入的 gRPC v2 客户端)合并。结果按 group + topic 去重,因此应答不随在线消费者数量膨胀,也不取决于查询的是哪个 proxy。
+- **客户端定向 RPC 转发到持有该客户端的 proxy。** `PrintThreadStackTrace`、`VerifyMessage`、`GetConsumerRunningInfo` 需要只有持有方 proxy 才有的实时 telemetry 流。`ProxyAdminForwarder` 识别出属于 peer 的客户端(其 `ConsumerManager` 条目是 `RemoteChannel`,`getRemoteProxyIp()` 为 peer 的 `localServeAddr`),把整个 RPC——连同原始 metadata——转发到 peer 的 admin 端口,并用 `x-mq-admin-forwarded` 头防止转发成环。peer 的 admin 端口按集群统一假设(心跳同步载荷不携带它); 异构 admin 端口的集群需要先把它加入心跳记录。
+
+`GetProxyRuntimeStats` 刻意报告**本进程**的状态(自身的连接/生产者/消费者计数),因此想要按节点统计的 dashboard 直接查询每个 proxy。
+
+### D4 — 每个 RPC 的数据源
+
+| 数据源 | RPC | 机制 |
+|---|---|---|
+| 集群可见连接 | ListConsumerConnection, ListSubscription, DescribeSubscription | broker 侧 `ConsumerConnection`(remoting + proxy 间同步的客户端)与本 proxy 的 `ConsumerManager`(本地 gRPC v2 客户端)合并 |
+| 客户端定向 telemetry relay | PrintThreadStackTrace, VerifyMessage, GetConsumerRunningInfo | relay 到持有方 `GrpcClientChannel` 的 telemetry command; 客户端在 peer 上时转发到 peer proxy(D3)|
+| Broker 网关(异步、多 broker fan-out)| DescribeTopicStatus, DescribeGroupAccumulation, ResetGroupOffset, QueryMessage, QueryTimeSpan, GetTopicRoute, DeleteSubscription | proxy 自己的异步 `AdminService` remoting 网关; 每个 broker hop 有独立 deadline,慢/无响应的 broker 不会挂起整个 RPC |
+| Proxy producer 路径 | AdminSendMessage | `MessagingProcessor.sendMessage`(定时 / FIFO 消息通过 system properties 生效)|
+| Proxy 运行时 | ChangeLogLevel | root logger 上重定位的 logback API |
+
+所有 broker 侧调用都走异步 `AdminService` 网关并并发扇出到所有相关 broker,因此 gRPC 执行线程不会被阻塞。
+
+### D5 — 协议覆盖
+
+连接/订阅列表是集群可见的,同时包含 remoting 客户端(通过 broker 侧 `ConsumerConnection`)和 gRPC v2 客户端(通过 proxy 的 `ConsumerManager`)。客户端定向 telemetry RPC(`PrintThreadStackTrace`、`VerifyMessage`、`GetConsumerRunningInfo`)作用于持有 proxy telemetry 流的 gRPC v2 客户端,并支持跨 proxy 转发; 它们的完整 running-info 载荷受 telemetry 协议能承载的内容限制(见 §8 诚实边界)。
+
+## 5. 能力映射
+
+| 需求 | 交付为 | 说明 |
+|---|---|---|
+| ListClients(按 group/topic/clientId 前缀过滤,分页)| `ListConsumerConnection(group[, topic])` | clientId 前缀过滤和分页在上游契约里无法表达; 这是本次部分交付的已记录边界 |
+| DescribeClient(SDK 版本、订阅、心跳、鉴权、Pop 进度)| `DescribeSubscription`(每客户端订阅)+ `GetConsumerRunningInfo`(订阅 + 运行信息)| 心跳/鉴权状态/Pop 进度字段在上游契约里不存在 |
+| ListClientsByGroup / ListClientsByTopic | `ListConsumerConnection(group[, topic])` | 完全覆盖 |
+| 多 proxy 集群聚合 | 集群可见连接数据 + 客户端定向 RPC 转发 | 见 D3 |
+
+M2+ 项(配置热更新、配额、连接控制、Pop/batch 诊断、路由观测流)不属于上游 `Admin` 契约,不在本次交付范围内。
+
+## 6. 构建 proto 源码
+
+`apache/rocketmq-apis` 仓库(main 分支,包含 `apache/rocketmq/v2/admin.proto`)作为 **git submodule** 引入(`rocketmq-apis/`,见 `.gitmodules`); CI workflow 用 `submodules: true` 检出。
+
+- **Maven**:`rocketmq-proto` 模块在构建时通过 `protobuf-maven-plugin` 为 `apache/rocketmq/v2/{definition,service,admin}.proto` 生成 Java + gRPC stub; 它的版本跟随 reactor(`${revision}`),因此构建本仓库不需要已发布的 proto 制品。
+- **Bazel**:`//rocketmq-proto:rocketmq-proto` 通过对 submodule 的 `genrule`(作为 `@rocketmq_apis` 外部仓库呈现)构建相同的类,使用 pin 的 `protoc` / `protoc-gen-grpc-java` 二进制和 well-known-type proto。
+- 其他消费者(dashboard、SDK)继续使用 rocketmq-apis 发布的制品; 只有本仓库从源码构建 proto。
+
+## 7. 配置参考
+
+| Key | 默认值 | 含义 |
+|---|---|---|
+| `grpcAdminServerEnable` | false | kill switch; admin 接口是 opt-in 的。设为 `true` 启动 admin gRPC server; `false`(默认)= 不启动 |
+| `grpcAdminServerPort` | 8088 | 专用 admin gRPC 端口(<=0 禁用)|
+| `grpcAdminServerAuthEnable` | false | fail-closed 模式; 要求集群认证**和**授权都已开启(见 D2)|
+| `grpcAdminServerRequestTimeoutMillis` | 3000 | admin RPC 扇出的每个 broker hop 的 deadline |
+
+## 8. 诚实边界
+
+admin 接口绝不填写自己无法如实提供的字段; 下列情况以明确 status 回应或留空,而不是伪造:
+
+- **DeleteSubscription 在开源里事实上是 no-op。** 开源代码没有任何地方写入 `SubscriptionGroupConfig.subscriptionDataSet`(broker 只读它),因此没有持久化的 per-topic 订阅可删。该 RPC 返回 `NOT_FOUND` 并说明原因,**消费组及其位点保持不动**。需要该 RPC 的发行版用外部订阅存储填充同一字段。
+- **QueryMessage** 支持按 `message_id`(unique-key 索引)和 `message_key` 查询。`subscription` / `lite_topic` / 纯时间范围扫描三种变体需要开源 broker 不提供的分页游标,返回 `BAD_REQUEST`。
+- **GetProxyRuntimeStats** 的 `in_tps` / `out_tps` 留空:开源 proxy 没有进程级吞吐计数器,填 0 会与空闲 proxy 无法区分。
+- **GetConsumerRunningInfo** 对 gRPC v2 客户端只返回 `subscriptions`:v2 telemetry 协议没有承载 `properties`、`message_queue_table`、`consume_status_table` 的回包。该 RPC 返回 `OK` 并附说明性 status message,而不是空壳。
+- **DescribeTopicStatus** 的 `create_timestamp` / `tags` 留空,因为 broker 不记录 topic 创建时间。
+
+## 9. 里程碑
+
+- M1(本次交付):基于上游 `Admin` service 的在线客户端查询 — ListConsumerConnection / DescribeSubscription / GetConsumerRunningInfo / DescribeGroupAccumulation,以集群可见方式并支持跨 proxy 转发提供,外加完整的 16-RPC `Admin` 接口用于 dashboard 集成。
+- 未来(需要上游 proto 演进):客户端列表的 clientId 前缀过滤 / 分页、心跳和鉴权状态字段,以及 M2+ 接口(配置/配额/连接/路由观测)。
+
+## 10. 验收标准映射
+
+| 标准 | 状态 |
+|---|---|
+| 设计文档 + 稳定向后兼容的 proto 契约 | 本文档 + 上游 `admin.proto`(rocketmq-apis main)|
+| 客户端查询 RPC 合入服务端仓库 | `ProxyAdminGrpcService` 在 proxy 上实现全部 16 个 `Admin` RPC |
+| 独立 ACL 控制、只读/高危分离、最小权限文档 | D2 资源/动作 + 下方 §11(最小权限配置指南)|
+| 与 dashboard 的 E2E | 契约 = 上游 `Admin` service,为 dashboard 集成冻结(跨仓库)|
+
+## 11. 最小权限配置指南
+
+admin 接口对每个 RPC 按专用的 `proxy.admin.*` ACL 2.0 资源鉴权(见上方 §4 决策 D2)。本节给出每个运维角色的最小权限策略。
+
+### 11.1 资源与动作模型
+
+资源(ACL 2.0 key; 建模为带保留名称的 cluster 类型字面量):
+
+| 资源 key | 保护 |
+|---|---|
+| `cluster:proxy.admin.client` | 在线客户端查询与客户端诊断 |
+| `cluster:proxy.admin.config` | proxy 运行时日志级别变更 |
+| `cluster:proxy.admin.connection` | 客户端定向 telemetry 命令:线程栈、verify message(高危)|
+| `cluster:proxy.admin.route` | topic 路由视图 |
+| `cluster:proxy.admin.ops` | broker 侧运维:stats/topic status/message query(读)和 reset offset / delete subscription / admin send(高危)|
+
+动作分类:
+
+- 只读:`Get`, `List`
+- 高权限(变更 / 破坏性):`Update`, `Delete`, `Pub`
+
+服务端把每个 RPC 映射到恰好一个(资源, 动作)对; 授予只读动作绝不会授权高权限 RPC。
+
+### 11.2 角色模板
+
+所有命令针对集群的任意 broker/namesrv 运行(ACL 2.0 存储)。先创建用户:
+
+```bash
+sh mqadmin createUser -n <namesrv-addr> -u <username> -p <password>
+```
+
+#### 角色 A — 只读观察者(dashboard 服务账号)
+
+在线客户端、订阅、堆积、诊断、配置/路由视图。
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-ro \
+ -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+ -a Get,List \
+ -d Allow
+```
+
+说明:`proxy.admin.ops` 上的 `Get,List` 覆盖只读的 broker 侧 RPC; 变更类 ops RPC 需要 `Update`/`Delete`/`Pub`,仍被拒绝。
+
+#### 角色 B — on-call 运维(观察者 + 客户端诊断命令)
+
+角色 A 加上向已连接客户端下发 telemetry 命令(线程栈 / verify message)的能力。
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-oncall \
+ -r cluster:proxy.admin.connection \
+ -a Update \
+ -d Allow
+# 外加上面的角色 A 授权
+```
+
+#### 角色 C — Admin(完全控制,break-glass)
+
+offset 重置、订阅删除、admin send、客户端诊断命令。
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-admin \
+ -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.connection,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+ -a Get,List,Update,Delete,Pub \
+ -d Allow
+```
+
+(把角色 C 账号控制在最小范围; 每次使用都会以 `[PROXY-ADMIN-AUDIT]` 前缀记录在鉴权审计日志里。)
+
+#### 环境限制(推荐)
+
+通过 `-i` sourceIp 选项把 admin 访问限制到运维网络:
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-ro \
+ -r cluster:proxy.admin.client \
+ -a Get,List \
+ -d Allow \
+ -i 10.0.0.0/8
+```
+
+### 11.3 Fail-Closed 模式
+
+默认情况下 admin server 遵循集群级认证/授权开关(与数据面行为相同)。fail-closed 模式让 admin 接口拒绝服务任何它无法完全授权的请求:
+
+```properties
+# proxy.json / -D grpcAdminServerAuthEnable=true
+grpcAdminServerAuthEnable: true
+```
+
+`grpcAdminServerAuthEnable=true` 时,admin 接口要求集群认证**和**授权开关都开启。认证关闭时请求以 `UNAUTHENTICATED` 拒绝; 授权关闭时以 `FAILED_PRECONDITION` 拒绝。这保证 `proxy.admin.*` ACL 被真正强制执行——当 admin 端口无法做网络隔离时使用。
+
+### 11.4 启用 / 禁用接口
+
+接口**默认关闭**(`grpcAdminServerEnable=false`)。要启用:
+
+```properties
+grpcAdminServerEnable: true # 在 grpcAdminServerPort 上启动 admin gRPC server
+```
+
+要保持禁用(默认),保留 `grpcAdminServerEnable: false`,或把 `grpcAdminServerPort` 设为 0 / 负数,此时 admin gRPC server 完全不启动。
+
+### 11.5 审计
+
+每个被服务的 admin RPC 都会把 subject(Console 登录用户 / AK)、method、resource、action 和 source IP 记录到鉴权审计 logger; 被拒绝的请求由 ACL 2.0 引擎自身记录。这满足审计四元组要求(Console 用户 + AK + 资源 + 操作)。
diff --git a/docs/proxy-admin.md b/docs/proxy-admin.md
new file mode 100644
index 0000000000..7380b6f966
--- /dev/null
+++ b/docs/proxy-admin.md
@@ -0,0 +1,370 @@
+# Proxy Admin Management Interface
+
+> 中文版见 `docs/proxy-admin-zh.md`。
+
+## 1. Motivation
+
+RocketMQ 5.0 moved client access behind the stateless Proxy, but operations still
+observe clients through broker-side structures (`ConsumerManager` on the broker,
+Remoting-era admin commands). gRPC clients attached to a Proxy are invisible to
+those tools: the control plane cannot answer "which SDK clients are online, what
+do they subscribe to, are they healthy" without indirect metrics heuristics.
+
+A control-plane dashboard needs a standard server-side interface to read complete
+gRPC client data. This change implements that interface on the Proxy itself.
+
+## 2. Contract choice: upstream `Admin` service
+
+The gRPC contract is the **upstream `Admin` service** defined in
+`apache/rocketmq/v2/admin.proto` of the `apache/rocketmq-apis` repository (main
+branch, consumed as a git submodule — see §6). This implementation deliberately
+does NOT introduce a bespoke `ProxyAdminService` in the protocol layer: the
+control-plane RPC surface lives in the same versioned, community-reviewed proto
+that the dashboard and multi-language SDKs already consume, and stays
+additive-compatible with upstream evolution.
+
+The proxy implements `AdminGrpc.AdminImplBase` (all 16 RPCs) and binds it on a
+dedicated admin gRPC server.
+
+## 3. Goals
+
+1. A dedicated, independent gRPC Admin server on the Proxy, isolated from the
+ data-plane `MessagingService`.
+2. Stable, backward-compatible contract from rocketmq-apis main (`Admin`
+ service, `apache/rocketmq/v2/admin.proto`).
+3. First-class authorization under dedicated `proxy.admin.*` ACL 2.0 resources
+ with read-only / high-privilege action separation.
+4. Cluster-wide semantics without leaking topology into the protocol: every
+ proxy exposes a cluster-visible view of connection/subscription data and
+ forwards client-directed RPCs to the proxy that owns the client.
+
+## 4. Design Decisions
+
+### D1 — Service placement
+
+The upstream `Admin` service is bound on its own gRPC server/port
+(`grpcAdminServerPort`, default 8088) with its own interceptor chain, separate
+from the data plane (8081). The whole surface is gated by a global kill switch
+`grpcAdminServerEnable`, which **defaults to false**: the admin server is opt-in
+and is not started unless explicitly enabled.
+
+Rationale: control-plane traffic must never contend with the data plane, and the
+admin port can be firewalled to the operations network only. The admin server
+intentionally does NOT expose channelz or proto reflection. It reuses the data
+plane's `GrpcChannelManager` / `GrpcClientSettingsManager` so online clients are
+visible to admin queries. Runtime executors introduced by this feature are
+instance-owned and registered in the Proxy lifecycle: the admin timeout scheduler
+stops with `ProxyAdminGrpcService`, and the blocking gateway executor stops with
+`AdminService`.
+
+### D2 — Authorization: dedicated `proxy.admin.*` resources
+
+Credentials arrive in the standard gRPC `Authorization` metadata (same scheme as
+the data plane, ACL 2.0 signature). Authentication runs as an interceptor, ordered
+**after** the standard pipeline's `HeaderInterceptor` (registered before it, since
+gRPC invokes interceptors in reverse registration order). That ordering is
+load-bearing: `HeaderInterceptor` replaces the inbound `x-mq-channel-id` with the
+channel id derived from the transport, and authentication results are cached per
+channel id, so authenticating on the client-supplied value would let a request on
+one connection reuse another connection's cached success and skip signature
+verification.
+
+Every RPC maps to one resource + one action:
+
+| Resource | RPCs | Actions |
+|---|---|---|
+| `proxy.admin.client` | ListSubscription, ListConsumerConnection (List); DescribeSubscription, DescribeGroupAccumulation, GetConsumerRunningInfo, QueryTimeSpan (Get) | List / Get |
+| `proxy.admin.config` | ChangeLogLevel | Update |
+| `proxy.admin.connection` | PrintThreadStackTrace, VerifyMessage | Update (high privilege) |
+| `proxy.admin.route` | GetTopicRoute | Get |
+| `proxy.admin.ops` | GetProxyRuntimeStats, DescribeTopicStatus, QueryMessage (Get); ResetGroupOffset (Update); DeleteSubscription (Delete); AdminSendMessage (Pub) | Get / Update / Delete / Pub |
+
+Modeling note: ACL 2.0 resource types are cluster/namespace/topic/group. The
+admin resources are modeled as CLUSTER-typed literals with reserved names
+(resource keys `cluster:proxy.admin.<module>`), which yields exact
+least-privilege matching without colliding with real cluster names and without
+changing the auth core.
+
+Modes:
+- cluster auth disabled, `grpcAdminServerAuthEnable=false` → open surface (same
+ semantics as the data plane);
+- cluster auth enabled → standard authenticate + authorize pipeline against the
+ per-method `proxy.admin.*` resource;
+- `grpcAdminServerAuthEnable=true` → fail-closed. The admin surface must be able
+ to both authenticate the caller **and** enforce the per-method ACL, so this
+ mode requires the cluster-wide authentication **and** authorization switches to
+ be on. If either is off every request is refused (authentication off →
+ `UNAUTHENTICATED`; authorization off → `FAILED_PRECONDITION`) rather than served
+ without a real permission check. This is deliberate: the ACL 2.0 evaluator is
+ itself gated by the cluster authorization switch, so serving requests with
+ authorization off would silently pass every call, including the destructive
+ ones.
+
+Audit: every served RPC writes `[PROXY-ADMIN-AUDIT] subject/method/resource/
+action/sourceIp` to the auth audit logger (satisfies the Console-user + AK +
+resource + operation audit tuple requirement together with the ACL 2.0 engine's
+own audit log).
+
+### D3 — Multi-proxy semantics: cluster-visible, no topology in the protocol
+
+A gRPC client is attached to exactly one proxy at a time, but a dashboard must
+still see the whole cluster from any single admin endpoint. Two mechanisms make
+that work without adding scope/peering fields to the protocol:
+
+- **Connection / subscription data is cluster-visible.** The listing RPCs
+ (`ListConsumerConnection`, `ListSubscription`, `DescribeSubscription`) merge
+ the broker-side `ConsumerConnection` view (which covers remoting clients, and
+ gRPC clients replicated between proxies by `HeartbeatSyncer`) with this proxy's
+ own `ConsumerManager` (which covers the gRPC v2 clients attached locally). The
+ result is deduplicated by group + topic, so the answer does not scale with the
+ number of online consumers and does not depend on which proxy is queried.
+- **Client-directed RPCs are forwarded to the owning proxy.**
+ `PrintThreadStackTrace`, `VerifyMessage` and `GetConsumerRunningInfo` need the
+ live telemetry stream that only the owning proxy holds. `ProxyAdminForwarder`
+ detects a client owned by a peer (its `ConsumerManager` entry is a
+ `RemoteChannel` whose `getRemoteProxyIp()` is the peer's `localServeAddr`) and
+ forwards the whole RPC — original metadata included — to the peer's admin port,
+ guarding against loops with the `x-mq-admin-forwarded` header. The peer admin
+ port is assumed uniform across the cluster (the heartbeat sync payload does not
+ carry it); a cluster running heterogeneous admin ports would need that added to
+ the heartbeat record first.
+
+`GetProxyRuntimeStats` intentionally reports the **local** process (its own
+connection/producer/consumer counts), so a dashboard that wants per-node stats
+queries each proxy directly.
+
+### D4 — Data sources per RPC
+
+| Source | RPCs | Mechanism |
+|---|---|---|
+| Cluster-visible connections | ListConsumerConnection, ListSubscription, DescribeSubscription | broker-side `ConsumerConnection` (remoting + peer-synced clients) merged with this proxy's `ConsumerManager` (local gRPC v2 clients) |
+| Client-directed telemetry relay | PrintThreadStackTrace, VerifyMessage, GetConsumerRunningInfo | telemetry command relayed to the owning `GrpcClientChannel`; forwarded to the peer proxy when the client is remote (D3) |
+| Broker gateway (async, multi-broker fan-out) | DescribeTopicStatus, DescribeGroupAccumulation, ResetGroupOffset, QueryMessage, QueryTimeSpan, GetTopicRoute, DeleteSubscription | the proxy's own async `AdminService` remoting gateway; each broker hop is bounded by its own deadline so a slow/unresponsive broker cannot hang the RPC |
+| Proxy producer path | AdminSendMessage | `MessagingProcessor.sendMessage` (system properties honoured for timer / FIFO messages) |
+| Proxy runtime | ChangeLogLevel | relocated logback API on the root logger |
+
+Every broker-facing call goes through the asynchronous `AdminService` gateway and
+is fanned out to all relevant brokers concurrently, so the gRPC executor thread is
+never blocked.
+
+### D5 — Protocol coverage
+
+The connection/subscription listings are cluster-visible and include both
+remoting clients (via the broker-side `ConsumerConnection`) and gRPC v2 clients
+(via the proxy's `ConsumerManager`). The client-directed telemetry RPCs
+(`PrintThreadStackTrace`, `VerifyMessage`, `GetConsumerRunningInfo`) operate on the
+gRPC v2 client that holds a proxy telemetry stream, with cross-proxy forwarding;
+their full running-info payload is bounded by what the telemetry protocol can
+carry (see §8, honest boundaries).
+
+## 5. Capability mapping
+
+| Requirement | Delivered as | Notes |
+|---|---|---|
+| ListClients (filter by group/topic/clientId prefix, paged) | `ListConsumerConnection(group[, topic])` | clientId-prefix filter and pagination are not expressible in the upstream contract; documented boundary of this partial delivery |
+| DescribeClient (SDK version, subscriptions, heartbeat, auth, Pop progress) | `DescribeSubscription` (per-client subscriptions) + `GetConsumerRunningInfo` (subscriptions + running info) | heartbeat/auth-status/Pop-progress fields do not exist in the upstream contract |
+| ListClientsByGroup / ListClientsByTopic | `ListConsumerConnection(group[, topic])` | fully covered |
+| Multi-proxy cluster aggregation | cluster-visible connection data + client-directed RPC forwarding | see D3 |
+
+M2+ items (config hot update, quotas, connection control, Pop/batch diagnostics,
+route observation streaming) are not part of the upstream `Admin` contract and
+are out of scope for this delivery.
+
+## 6. Building the proto sources
+
+The `apache/rocketmq-apis` repository (main branch, containing
+`apache/rocketmq/v2/admin.proto`) is consumed as a **git submodule**
+(`rocketmq-apis/`, see `.gitmodules`); CI workflows check it out with
+`submodules: true`.
+
+- **Maven**: the `rocketmq-proto` module generates the Java + gRPC stubs for
+ `apache/rocketmq/v2/{definition,service,admin}.proto` at build time via
+ `protobuf-maven-plugin`; its version follows the reactor (`${revision}`), so
+ no published proto artifact is required to build this repository.
+- **Bazel**: `//rocketmq-proto:rocketmq-proto` builds the same classes through a
+ `genrule` over the submodule (surfaced as the `@rocketmq_apis` external
+ repository), using pinned `protoc` / `protoc-gen-grpc-java` binaries and the
+ well-known-type protos.
+- Other consumers (dashboard, SDKs) continue to use the artifact published by
+ rocketmq-apis; only this repository builds the proto from source.
+
+## 7. Configuration Reference
+
+| Key | Default | Meaning |
+|---|---|---|
+| `grpcAdminServerEnable` | false | kill switch; the admin surface is opt-in. Set `true` to start the admin gRPC server; `false` (default) = not started |
+| `grpcAdminServerPort` | 8088 | dedicated admin gRPC port (<=0 disables) |
+| `grpcAdminServerAuthEnable` | false | fail-closed mode; requires cluster authentication **and** authorization to be enabled (see D2) |
+| `grpcAdminServerRequestTimeoutMillis` | 3000 | per-broker-hop deadline for the broker calls an admin RPC fans out to |
+
+## 8. Honest boundaries
+
+The admin surface never fills a field it cannot truthfully supply; the following
+are answered with an explicit status or left unset rather than faked:
+
+- **DeleteSubscription is effectively a no-op in open source.** Nothing in the
+ open-source codebase writes `SubscriptionGroupConfig.subscriptionDataSet` (the
+ broker only reads it), so there is no persisted per-topic subscription to
+ remove. The RPC returns `NOT_FOUND` with an explanation and **leaves the
+ consumer group and its offsets untouched**. Distributions that need this RPC
+ back it with an external subscription store that populates the same field.
+- **QueryMessage** supports lookup by `message_id` (unique-key index) and
+ `message_key`. The `subscription` / `lite_topic` / pure time-range scan variants
+ need a paging cursor the open-source broker does not provide and return
+ `BAD_REQUEST`.
+- **GetProxyRuntimeStats** leaves `in_tps` / `out_tps` unset: the open-source
+ proxy keeps no per-process throughput counter, and reporting 0 would be
+ indistinguishable from an idle proxy.
+- **GetConsumerRunningInfo** over a gRPC v2 client returns only `subscriptions`:
+ the v2 telemetry protocol has no reply message carrying `properties`,
+ `message_queue_table` or `consume_status_table`. The RPC returns `OK` with an
+ explanatory status message rather than an empty shell.
+- **DescribeTopicStatus** leaves `create_timestamp` / `tags` unset because the
+ broker does not record a topic creation time.
+
+## 9. Milestones
+
+- M1 (this delivery): online client query over the upstream `Admin` service —
+ ListConsumerConnection / DescribeSubscription / GetConsumerRunningInfo /
+ DescribeGroupAccumulation, served cluster-visibly with cross-proxy forwarding,
+ plus the full 16-RPC `Admin` surface for dashboard integration.
+- Future (needs upstream proto evolution): clientId-prefix filter / pagination
+ for client listings, heartbeat & auth-status fields, and the M2+ surfaces
+ (config/quota/connection/route observation).
+
+## 10. Acceptance Criteria Mapping
+
+| Criterion | Status |
+|---|---|
+| Design document + stable backward-compatible proto contract | this document + upstream `admin.proto` (rocketmq-apis main) |
+| Client query RPCs merged into the server repo | `ProxyAdminGrpcService` implements all 16 `Admin` RPCs on the proxy |
+| Independent ACL control, read-only/high-risk separation, least-privilege doc | D2 resources/actions + §11 (Least-Privilege Configuration Guide) below |
+| E2E with the dashboard | contract = upstream `Admin` service, frozen for dashboard integration (cross-repo) |
+
+## 11. Least-Privilege Configuration Guide
+
+The admin surface authorizes every RPC against dedicated `proxy.admin.*`
+ACL 2.0 resources (see §4 decision D2 above). This section shows the
+minimum-permission policy for each operational role.
+
+### 11.1 Resource & Action Model
+
+Resources (ACL 2.0 keys; modeled as cluster-typed literals with reserved names):
+
+| Resource key | Protects |
+|---|---|
+| `cluster:proxy.admin.client` | online client query & client diagnostics |
+| `cluster:proxy.admin.config` | proxy runtime log-level change |
+| `cluster:proxy.admin.connection` | client-directed telemetry commands: thread stack, verify message (HIGH) |
+| `cluster:proxy.admin.route` | topic route view |
+| `cluster:proxy.admin.ops` | broker-facing ops: stats/topic status/message query (read) and reset offset / delete subscription / admin send (HIGH) |
+
+Action classes:
+
+- Read-only: `Get`, `List`
+- High privilege (mutating / disruptive): `Update`, `Delete`, `Pub`
+
+The server maps every RPC to exactly one (resource, action) pair; granting a
+read-only action can never authorize a high-privilege RPC.
+
+### 11.2 Role Templates
+
+All commands run against any broker/namesrv of the cluster (ACL 2.0 storage).
+Create users first:
+
+```bash
+sh mqadmin createUser -n <namesrv-addr> -u <username> -p <password>
+```
+
+#### Role A — Read-only observer (dashboard service account)
+
+Online clients, subscriptions, accumulation, diagnostics, config/route views.
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-ro \
+ -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+ -a Get,List \
+ -d Allow
+```
+
+Note: `Get,List` on `proxy.admin.ops` covers the read-only broker-facing RPCs;
+the mutating ops RPCs require `Update`/`Delete`/`Pub` and stay denied.
+
+#### Role B — On-call operator (observer + client diagnostics commands)
+
+Role A plus the ability to direct telemetry commands (thread stack / verify
+message) at a connected client.
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-oncall \
+ -r cluster:proxy.admin.connection \
+ -a Update \
+ -d Allow
+# plus the Role A grant above
+```
+
+#### Role C — Admin (full control, break-glass)
+
+Offset reset, subscription deletion, admin send, client diagnostics commands.
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-admin \
+ -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.connection,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+ -a Get,List,Update,Delete,Pub \
+ -d Allow
+```
+
+(Keep Role C accounts to a minimum; every use is recorded in the auth audit
+log with the `[PROXY-ADMIN-AUDIT]` prefix.)
+
+#### Environment restriction (recommended)
+
+Restrict admin access to the operations network via the `-i` sourceIp option:
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+ -s user:rip2-ro \
+ -r cluster:proxy.admin.client \
+ -a Get,List \
+ -d Allow \
+ -i 10.0.0.0/8
+```
+
+### 11.3 Fail-Closed Mode
+
+By default the admin server follows the cluster-wide authentication/authorization
+switches (same behavior as the data plane). Fail-closed mode makes the admin
+surface refuse to serve anything it cannot fully authorize:
+
+```properties
+# proxy.json / -D grpcAdminServerAuthEnable=true
+grpcAdminServerAuthEnable: true
+```
+
+With `grpcAdminServerAuthEnable=true`, the admin surface requires the cluster
+authentication **and** authorization switches to be enabled. If authentication is
+off, requests are rejected with `UNAUTHENTICATED`; if authorization is off, they
+are rejected with `FAILED_PRECONDITION`. This guarantees the `proxy.admin.*` ACL
+is genuinely enforced — use it when the admin port cannot be network-isolated.
+
+### 11.4 Enabling / Disabling the Surface
+
+The surface is **off by default** (`grpcAdminServerEnable=false`). To enable it:
+
+```properties
+grpcAdminServerEnable: true # start the admin gRPC server on grpcAdminServerPort
+```
+
+To keep it disabled (the default), leave `grpcAdminServerEnable: false`, or set
+`grpcAdminServerPort` to 0 / negative — the admin gRPC server is then not started
+at all.
+
+### 11.5 Audit
+
+Every served admin RPC logs subject (Console login user / AK), method, resource,
+action and source IP to the auth audit logger; denied requests are logged by the
+ACL 2.0 engine itself. This satisfies the four-tuple audit requirement
+(Console user + AK + resource + operation).
diff --git a/pom.xml b/pom.xml
index 9cb13dde1f..e2a96df4a6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -127,9 +127,8 @@
<annotations-api.version>6.0.53</annotations-api.version>
<extra-enforcer-rules.version>1.0-beta-4</extra-enforcer-rules.version>
<concurrentlinkedhashmap-lru.version>1.4.2</concurrentlinkedhashmap-lru.version>
- <rocketmq-proto.version>2.1.2</rocketmq-proto.version>
<grpc.version>1.53.0</grpc.version>
- <protobuf.version>3.20.1</protobuf.version>
+ <protobuf.version>3.24.4</protobuf.version>
<disruptor.version>1.2.10</disruptor.version>
<org.relection.version>0.9.11</org.relection.version>
<caffeine.version>2.9.3</caffeine.version>
@@ -199,6 +198,7 @@
<module>example</module>
<module>container</module>
<module>controller</module>
+ <module>rocketmq-proto</module>
<module>proxy</module>
<module>tieredstore</module>
</modules>
@@ -663,7 +663,7 @@
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>rocketmq-proto</artifactId>
- <version>${rocketmq-proto.version}</version>
+ <version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>*</groupId>
diff --git a/proxy/BUILD.bazel b/proxy/BUILD.bazel
index 8ddaa52382..f5883a2107 100644
--- a/proxy/BUILD.bazel
+++ b/proxy/BUILD.bazel
@@ -57,7 +57,7 @@ java_library(
"@maven//:io_opentelemetry_opentelemetry_sdk_common",
"@maven//:io_opentelemetry_opentelemetry_sdk_metrics",
"@maven//:org_apache_commons_commons_lang3",
- "@maven//:org_apache_rocketmq_rocketmq_proto",
+ "//rocketmq-proto:rocketmq-proto",
"@maven//:org_checkerframework_checker_qual",
"@maven//:at_yawk_lz4_lz4_java",
"@maven//:org_slf4j_slf4j_api",
@@ -103,7 +103,7 @@ java_library(
"@maven//:io_opentelemetry_opentelemetry_exporter_otlp",
"@maven//:io_opentelemetry_opentelemetry_exporter_prometheus",
"@maven//:io_opentelemetry_opentelemetry_sdk",
- "@maven//:org_apache_rocketmq_rocketmq_proto",
+ "//rocketmq-proto:rocketmq-proto",
"@maven//:org_checkerframework_checker_qual",
"@maven//:org_slf4j_slf4j_api",
"@maven//:org_springframework_spring_core",
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java b/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java
index 1b38a19ae6..5c56f0fe55 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java
@@ -39,7 +39,13 @@ import org.apache.rocketmq.proxy.config.ConfigurationManager;
import org.apache.rocketmq.proxy.config.ProxyConfig;
import org.apache.rocketmq.proxy.grpc.GrpcServer;
import org.apache.rocketmq.proxy.grpc.GrpcServerBuilder;
+import org.apache.rocketmq.proxy.grpc.admin.ProxyAdminAuthInterceptor;
+import org.apache.rocketmq.proxy.grpc.admin.ProxyAdminForwarder;
+import org.apache.rocketmq.proxy.grpc.admin.ProxyAdminGrpcService;
+import org.apache.rocketmq.proxy.grpc.v2.DefaultGrpcMessagingActivity;
import org.apache.rocketmq.proxy.grpc.v2.GrpcMessagingApplication;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcChannelManager;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
import org.apache.rocketmq.proxy.metrics.ProxyMetricsManager;
import org.apache.rocketmq.proxy.processor.DefaultMessagingProcessor;
import org.apache.rocketmq.proxy.processor.MessagingProcessor;
@@ -81,10 +87,13 @@ public class ProxyStartup {
TlsCertificateManager tlsCertificateManager = new TlsCertificateManager();
PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(tlsCertificateManager);
- // create grpcServer
+ // create grpcServer (data plane). Capture the application reference so the
+ // admin server can reuse the SAME GrpcChannelManager / GrpcClientSettingsManager
+ // that the data plane uses to track online clients.
+ GrpcMessagingApplication dataPlaneApplication = createServiceProcessor(messagingProcessor);
GrpcServer grpcServer = GrpcServerBuilder.newBuilder(executor,
ConfigurationManager.getProxyConfig().getGrpcServerPort(), tlsCertificateManager)
- .addService(createServiceProcessor(messagingProcessor))
+ .addService(dataPlaneApplication)
.addService(ChannelzService.newInstance(100))
.addService(ProtoReflectionService.newInstance())
.configInterceptor()
@@ -92,6 +101,41 @@ public class ProxyStartup {
.build();
PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(grpcServer);
+ // Dedicated admin gRPC server (control plane), gated by grpcAdminServerEnable. Reuses
+ // the data plane's GrpcChannelManager so admin queries can see the data-plane clients.
+ Integer adminPort = ConfigurationManager.getProxyConfig().getGrpcAdminServerPort();
+ if (ConfigurationManager.getProxyConfig().isGrpcAdminServerEnable()
+ && adminPort != null && adminPort > 0) {
+ DefaultGrpcMessagingActivity dataPlaneActivity =
+ (DefaultGrpcMessagingActivity) dataPlaneApplication.getGrpcMessagingActivity();
+ GrpcChannelManager sharedChannelManager = dataPlaneActivity.getGrpcChannelManager();
+ GrpcClientSettingsManager sharedSettingsManager = dataPlaneActivity.getGrpcClientSettingsManager();
+ DefaultMessagingProcessor defaultProcessor = (DefaultMessagingProcessor) messagingProcessor;
+
+ // Forwards client-targeted admin RPCs to the proxy that owns the client channel.
+ ProxyAdminForwarder adminForwarder =
+ new ProxyAdminForwarder(defaultProcessor.getServiceManager());
+ PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(adminForwarder);
+
+ ProxyAdminGrpcService adminService = new ProxyAdminGrpcService(
+ defaultProcessor.getServiceManager(),
+ messagingProcessor,
+ sharedChannelManager,
+ sharedSettingsManager,
+ adminForwarder);
+ PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(adminService);
+ GrpcServer adminGrpcServer = GrpcServerBuilder.newBuilder(executor, adminPort, tlsCertificateManager)
+ .addService(adminService)
+ // authentication has to see the channel id HeaderInterceptor derives from the
+ // transport, so it is ordered after the standard pipeline rather than appended
+ .configInterceptor(new ProxyAdminAuthInterceptor(
+ ConfigurationManager.getAuthConfig(), messagingProcessor))
+ .shutdownTime(ConfigurationManager.getProxyConfig().getGrpcShutdownTimeSeconds(), TimeUnit.SECONDS)
+ .build();
+ PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(adminGrpcServer);
+ log.info("admin gRPC server will start on port {}", adminPort);
+ }
+
RemotingProtocolServer remotingServer = new RemotingProtocolServer(messagingProcessor, tlsCertificateManager);
PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(remotingServer);
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java b/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java
index 8f6fb7ec1b..6ff495449c 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java
@@ -90,6 +90,33 @@ public class ProxyConfig implements ConfigFile {
*/
private String proxyMode = ProxyMode.CLUSTER.name();
private Integer grpcServerPort = 8081;
+
+ /**
+ * Dedicated gRPC port for the Proxy Admin service. When > 0 the proxy
+ * starts an independent admin gRPC server (separate ACL scope, isolated
+ * traffic) in addition to the data-plane gRPC server.
+ */
+ private Integer grpcAdminServerPort = 8088;
+ /**
+ * global kill switch for the Proxy Admin surface. When false the
+ * admin gRPC server is not started at all, regardless of {@link #grpcAdminServerPort}.
+ * Defaults to false: the admin surface is opt-in and must be explicitly enabled.
+ */
+ private boolean grpcAdminServerEnable = false;
+ /**
+ * When true the admin server enforces credential checks even if the cluster-wide
+ * authentication switch is off; requests without verifiable credentials are rejected
+ * (fail-closed mode). When false the admin server follows the cluster-wide
+ * authenticationEnabled/authorizationEnabled switches (same behavior as the data plane).
+ */
+ private boolean grpcAdminServerAuthEnable = false;
+ /**
+ * Per-request timeout in milliseconds for the broker calls an admin RPC fans out to.
+ * A single RPC may query several brokers concurrently, so this bounds each hop rather
+ * than the whole call.
+ */
+ private long grpcAdminServerRequestTimeoutMillis = 3000L;
+
private long grpcShutdownTimeSeconds = 30;
private int grpcBossLoopNum = 1;
private int grpcWorkerLoopNum = PROCESSOR_NUMBER * 2;
@@ -492,6 +519,38 @@ public class ProxyConfig implements ConfigFile {
this.grpcServerPort = grpcServerPort;
}
+ public Integer getGrpcAdminServerPort() {
+ return grpcAdminServerPort;
+ }
+
+ public void setGrpcAdminServerPort(Integer grpcAdminServerPort) {
+ this.grpcAdminServerPort = grpcAdminServerPort;
+ }
+
+ public boolean isGrpcAdminServerEnable() {
+ return grpcAdminServerEnable;
+ }
+
+ public void setGrpcAdminServerEnable(boolean grpcAdminServerEnable) {
+ this.grpcAdminServerEnable = grpcAdminServerEnable;
+ }
+
+ public boolean isGrpcAdminServerAuthEnable() {
+ return grpcAdminServerAuthEnable;
+ }
+
+ public void setGrpcAdminServerAuthEnable(boolean grpcAdminServerAuthEnable) {
+ this.grpcAdminServerAuthEnable = grpcAdminServerAuthEnable;
+ }
+
+ public long getGrpcAdminServerRequestTimeoutMillis() {
+ return grpcAdminServerRequestTimeoutMillis;
+ }
+
+ public void setGrpcAdminServerRequestTimeoutMillis(long grpcAdminServerRequestTimeoutMillis) {
+ this.grpcAdminServerRequestTimeoutMillis = grpcAdminServerRequestTimeoutMillis;
+ }
+
public long getGrpcShutdownTimeSeconds() {
return grpcShutdownTimeSeconds;
}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java
index 35d035b993..0619d09ba5 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.proxy.grpc;
import io.grpc.BindableService;
+import io.grpc.ServerBuilder;
import io.grpc.ServerInterceptor;
import io.grpc.ServerServiceDefinition;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
@@ -141,6 +142,13 @@ public class GrpcServerBuilder {
return this;
}
+ /**
+ * Registers an interceptor that runs <em>before</em> the pipeline installed by
+ * {@link #configInterceptor(ServerInterceptor...)}, because gRPC invokes server interceptors in
+ * reverse registration order. An interceptor that needs the normalized headers (the
+ * transport-derived channel id, the cleared authorization subject) must NOT be appended here;
+ * pass it to {@link #configInterceptor(ServerInterceptor...)} instead.
+ */
public GrpcServerBuilder appendInterceptor(ServerInterceptor interceptor) {
this.serverBuilder.intercept(interceptor);
return this;
@@ -174,11 +182,36 @@ public class GrpcServerBuilder {
return enableEpoll ? new EpollEventLoopGroup(threads) : new NioEventLoopGroup(threads);
}
- public GrpcServerBuilder configInterceptor() {
- this.serverBuilder
- .intercept(new GlobalExceptionInterceptor())
- .intercept(new ContextInterceptor())
- .intercept(new HeaderInterceptor());
+ /**
+ * Installs the standard pipeline, optionally with interceptors that must observe its normalized
+ * headers.
+ *
+ * @param postHeaderInterceptors interceptors to run after {@link HeaderInterceptor}, typically
+ * authentication
+ */
+ public GrpcServerBuilder configInterceptor(ServerInterceptor... postHeaderInterceptors) {
+ configureInterceptors(this.serverBuilder, postHeaderInterceptors);
return this;
}
+
+ /**
+ * Orders the standard pipeline and the interceptors that depend on it. gRPC invokes server
+ * interceptors in reverse registration order, so {@code postHeaderInterceptors} are registered
+ * first precisely so that they run last.
+ *
+ * <p>Authentication depends on this order. {@link HeaderInterceptor} replaces the inbound
+ * {@code x-mq-channel-id} with the channel id derived from the transport and clears the
+ * authorization subject. Authentication results are cached per channel id, so authenticating on
+ * the client-supplied value would let a request on one connection reuse the cached success of
+ * another connection and skip signature verification.
+ */
+ static void configureInterceptors(ServerBuilder<?> serverBuilder,
+ ServerInterceptor... postHeaderInterceptors) {
+ for (ServerInterceptor interceptor : postHeaderInterceptors) {
+ serverBuilder.intercept(interceptor);
+ }
+ serverBuilder.intercept(new GlobalExceptionInterceptor());
+ serverBuilder.intercept(new ContextInterceptor());
+ serverBuilder.intercept(new HeaderInterceptor());
+ }
}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverter.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverter.java
new file mode 100644
index 0000000000..2b7bb918d6
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverter.java
@@ -0,0 +1,372 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse.GroupAccumulation;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.MessageQueueItem;
+import apache.rocketmq.v2.MessageType;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import apache.rocketmq.v2.Resource;
+import apache.rocketmq.v2.Status;
+import com.alibaba.fastjson2.JSON;
+import com.google.protobuf.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.filter.ExpressionType;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+
+/**
+ * Bridge between the broker's internal wire types ({@code org.apache.rocketmq.remoting.*}) and the
+ * gRPC admin contract ({@code apache.rocketmq.v2.*}, generated from rocketmq-apis).
+ *
+ * <p>This is the only class that knows both worlds: {@link ProxyAdminGrpcService} stays
+ * protocol-pure and the admin gateway stays remoting-pure, so neither layer leaks into the other.
+ * Converting a whole message body is deliberately NOT done here — the data plane already has
+ * {@code GrpcConverter.buildMessage(MessageExt)}, which is reused instead of being duplicated.
+ */
+final class AdminModelConverter {
+
+ private AdminModelConverter() {
+ }
+
+ static Status ok() {
+ return Status.newBuilder().setCode(Code.OK).build();
+ }
+
+ // -------------------------------------------------------------------------
+ // topic
+ // -------------------------------------------------------------------------
+
+ static DescribeTopicStatusResponse toTopicStatus(TopicConfig topicConfig, String topic) {
+ DescribeTopicStatusResponse.Builder builder = DescribeTopicStatusResponse.newBuilder()
+ .setStatus(ok())
+ .setTopicMessageType(toMessageType(topicConfig == null ? null : topicConfig.getTopicMessageType()));
+ if (topicConfig != null) {
+ // a topic creation timestamp is not recorded by the broker, so create_timestamp and
+ // tags stay unset rather than being filled with a value that would only look real
+ builder.setDescription("readQueueNums=" + topicConfig.getReadQueueNums()
+ + ", writeQueueNums=" + topicConfig.getWriteQueueNums()
+ + ", perm=" + topicConfig.getPerm());
+ } else {
+ builder.setDescription("topic config not available for " + topic);
+ }
+ return builder.build();
+ }
+
+ static MessageType toMessageType(TopicMessageType topicMessageType) {
+ if (topicMessageType == null) {
+ return MessageType.MESSAGE_TYPE_UNSPECIFIED;
+ }
+ switch (topicMessageType) {
+ case NORMAL:
+ return MessageType.NORMAL;
+ case FIFO:
+ return MessageType.FIFO;
+ case DELAY:
+ return MessageType.DELAY;
+ case TRANSACTION:
+ return MessageType.TRANSACTION;
+ case LITE:
+ return MessageType.LITE;
+ case UNSPECIFIED:
+ case MIXED:
+ case PRIORITY:
+ default:
+ // MessageType.UNRECOGNIZED cannot be set on a builder, so anything this proxy does
+ // not understand degrades to UNSPECIFIED instead of failing the whole RPC
+ return MessageType.MESSAGE_TYPE_UNSPECIFIED;
+ }
+ }
+
+ static GetTopicRouteResponse toTopicRoute(TopicRouteData topicRouteData) {
+ return GetTopicRouteResponse.newBuilder()
+ .setStatus(ok())
+ .setTopicRouteData(topicRouteData == null ? "{}" : JSON.toJSONString(topicRouteData))
+ .build();
+ }
+
+ // -------------------------------------------------------------------------
+ // consume time span
+ // -------------------------------------------------------------------------
+
+ /**
+ * The broker already computes min/max/consume timestamps and the delay per queue
+ * (RequestCode.QUERY_CONSUME_TIME_SPAN), so the response maps 1:1 and no offset table lookup
+ * is needed here.
+ */
+ static QueryTimeSpanResponse toQueryTimeSpan(List<QueueTimeSpan> queueTimeSpans) {
+ QueryTimeSpanResponse.Builder builder = QueryTimeSpanResponse.newBuilder().setStatus(ok());
+ if (queueTimeSpans == null) {
+ return builder.build();
+ }
+ for (QueueTimeSpan span : queueTimeSpans) {
+ if (span == null || span.getMessageQueue() == null) {
+ continue;
+ }
+ long delay = span.getDelayTime();
+ builder.addQueueTimeSpanList(QueryTimeSpanResponse.QueueTimeSpan.newBuilder()
+ .setMessageQueue(toMessageQueue(span.getMessageQueue()))
+ .setMinTimestamp(span.getMinTimeStamp())
+ .setMaxTimestamp(span.getMaxTimeStamp())
+ .setConsumeTimestamp(span.getConsumeTimeStamp())
+ .setDelayTimeMs(Math.max(delay, 0L))
+ .build());
+ }
+ return builder.build();
+ }
+
+ static apache.rocketmq.v2.MessageQueue toMessageQueue(org.apache.rocketmq.common.message.MessageQueue mq) {
+ return apache.rocketmq.v2.MessageQueue.newBuilder()
+ .setTopic(Resource.newBuilder().setName(mq.getTopic()).build())
+ .setBroker(apache.rocketmq.v2.Broker.newBuilder().setName(mq.getBrokerName()).build())
+ .setId(mq.getQueueId())
+ .build();
+ }
+
+ // -------------------------------------------------------------------------
+ // accumulation
+ // -------------------------------------------------------------------------
+
+ /**
+ * Aggregates consume stats collected from every broker of the cluster.
+ *
+ * <p>Per the contract {@code accumulation = inflight + ready}, where inflight counts messages
+ * already delivered but not yet acknowledged ({@code pullOffset - consumerOffset}) and ready
+ * counts messages still waiting ({@code brokerOffset - pullOffset}). Pop retry topics are
+ * folded into their normal topic so a caller asking about a topic sees its whole backlog; the
+ * pull retry topic is counted in the group total but kept out of the per-topic map because it
+ * is not a user-visible topic.
+ *
+ * @param statsByBroker consume stats per broker address; a null topic filter means the broker
+ * returned every topic the group has offsets for
+ * @param group consumer group, needed to recognise its retry topics
+ */
+ static AccumulationResult toAccumulation(Map<String, ConsumeStats> statsByBroker, String group) {
+ long totalDiff = 0L;
+ long totalInflight = 0L;
+ long earliestLastConsume = Long.MAX_VALUE;
+ Map<String, long[]> byTopic = new HashMap<>();
+
+ String pullRetryTopic = MixAll.getRetryTopic(group);
+ for (ConsumeStats stats : statsByBroker.values()) {
+ if (stats == null || stats.getOffsetTable() == null) {
+ continue;
+ }
+ for (Map.Entry<org.apache.rocketmq.common.message.MessageQueue, OffsetWrapper> entry :
+ stats.getOffsetTable().entrySet()) {
+ org.apache.rocketmq.common.message.MessageQueue mq = entry.getKey();
+ OffsetWrapper wrapper = entry.getValue();
+ if (mq == null || wrapper == null) {
+ continue;
+ }
+ long diff = Math.max(wrapper.getBrokerOffset() - wrapper.getConsumerOffset(), 0L);
+ long inflight = Math.max(wrapper.getPullOffset() - wrapper.getConsumerOffset(), 0L);
+ if (inflight > diff) {
+ inflight = diff;
+ }
+ totalDiff += diff;
+ totalInflight += inflight;
+ if (diff > 0 && wrapper.getLastTimestamp() > 0) {
+ earliestLastConsume = Math.min(earliestLastConsume, wrapper.getLastTimestamp());
+ }
+
+ String topic = mq.getTopic();
+ if (pullRetryTopic.equals(topic)) {
+ continue;
+ }
+ String userTopic = KeyBuilder.parseNormalTopic(topic, group);
+ long[] acc = byTopic.computeIfAbsent(userTopic, k -> new long[3]);
+ acc[0] += diff;
+ acc[1] += inflight;
+ if (diff > 0 && wrapper.getLastTimestamp() > 0 && (acc[2] == 0L || wrapper.getLastTimestamp() < acc[2])) {
+ acc[2] = wrapper.getLastTimestamp();
+ }
+ }
+ }
+
+ AccumulationResult result = new AccumulationResult(buildAccumulation(totalDiff, totalInflight, earliestLastConsume));
+ for (Map.Entry<String, long[]> entry : byTopic.entrySet()) {
+ long[] acc = entry.getValue();
+ result.byTopic.put(entry.getKey(), buildAccumulation(acc[0], acc[1], acc[2]));
+ }
+ return result;
+ }
+
+ private static GroupAccumulation buildAccumulation(long accumulation, long inflight, long lastConsumeTimestamp) {
+ GroupAccumulation.Builder builder = GroupAccumulation.newBuilder()
+ .setAccumulation(accumulation)
+ .setInflightMessages(inflight)
+ .setReadyMessages(Math.max(accumulation - inflight, 0L));
+ if (lastConsumeTimestamp > 0 && lastConsumeTimestamp != Long.MAX_VALUE) {
+ builder.setLastConsumeTimestamp(lastConsumeTimestamp);
+ if (accumulation > 0) {
+ long delayMillis = Math.max(System.currentTimeMillis() - lastConsumeTimestamp, 0L);
+ builder.setDeliverDelayTime(Duration.newBuilder()
+ .setSeconds(delayMillis / 1000L)
+ .setNanos((int) (delayMillis % 1000L) * 1_000_000)
+ .build());
+ }
+ }
+ return builder.build();
+ }
+
+ static final class AccumulationResult {
+ final GroupAccumulation total;
+ final Map<String, GroupAccumulation> byTopic = new HashMap<>();
+
+ private AccumulationResult(GroupAccumulation total) {
+ this.total = total;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // clients
+ // -------------------------------------------------------------------------
+
+ /**
+ * Builds a {@code ClientInfo} from a broker-side connection. The broker sees consumers that
+ * registered through any proxy of the cluster, which is why the admin surface prefers this
+ * over scanning only the local gRPC channels.
+ */
+ static apache.rocketmq.v2.ClientInfo toClientInfo(Connection connection,
+ apache.rocketmq.v2.MessageModel messageModel) {
+ apache.rocketmq.v2.ClientInfo.Builder builder = apache.rocketmq.v2.ClientInfo.newBuilder()
+ .setClientId(connection.getClientId() == null ? "" : connection.getClientId());
+ if (connection.getLanguage() != null) {
+ builder.setLanguage(connection.getLanguage().name());
+ }
+ builder.setVersion(String.valueOf(connection.getVersion()));
+ String clientAddr = connection.getClientAddr();
+ if (clientAddr != null && !clientAddr.isEmpty()) {
+ // clientAddr is "ip:port"; egress_ip is documented as the address observed by the server
+ int idx = clientAddr.lastIndexOf('@');
+ String addr = idx >= 0 ? clientAddr.substring(idx + 1) : clientAddr;
+ int colon = addr.lastIndexOf(':');
+ builder.setEgressIp(colon > 0 ? addr.substring(0, colon) : addr);
+ builder.setHostname(idx >= 0 ? clientAddr.substring(0, idx) : "");
+ }
+ if (messageModel != null) {
+ builder.setMessageModel(messageModel);
+ }
+ return builder.build();
+ }
+
+ static apache.rocketmq.v2.MessageModel toMessageModel(
+ org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel messageModel) {
+ if (messageModel == null) {
+ return apache.rocketmq.v2.MessageModel.MESSAGE_MODEL_UNSPECIFIED;
+ }
+ switch (messageModel) {
+ case BROADCASTING:
+ return apache.rocketmq.v2.MessageModel.BROADCASTING;
+ case CLUSTERING:
+ default:
+ return apache.rocketmq.v2.MessageModel.CLUSTERING;
+ }
+ }
+
+ static apache.rocketmq.v2.FilterExpression toFilterExpression(String expressionType, String expression) {
+ apache.rocketmq.v2.FilterType type = ExpressionType.SQL92.equals(expressionType)
+ ? apache.rocketmq.v2.FilterType.SQL : apache.rocketmq.v2.FilterType.TAG;
+ return apache.rocketmq.v2.FilterExpression.newBuilder()
+ .setType(type)
+ .setExpression(expression == null ? "" : expression)
+ .build();
+ }
+
+ /**
+ * Full mapping of a remoting client's running info. A gRPC v2 client can only report its
+ * thread stack (the telemetry contract has no reply message carrying the other three fields),
+ * so this is reached only for remoting clients.
+ */
+ static apache.rocketmq.v2.ConsumerRunningInfo toConsumerRunningInfo(
+ org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo runningInfo) {
+ apache.rocketmq.v2.ConsumerRunningInfo.Builder builder = apache.rocketmq.v2.ConsumerRunningInfo.newBuilder();
+ if (runningInfo == null) {
+ return builder.build();
+ }
+ if (runningInfo.getProperties() != null) {
+ for (String name : runningInfo.getProperties().stringPropertyNames()) {
+ builder.putProperties(name, runningInfo.getProperties().getProperty(name));
+ }
+ }
+ if (runningInfo.getSubscriptionSet() != null) {
+ for (org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData data :
+ runningInfo.getSubscriptionSet()) {
+ builder.putSubscriptions(data.getTopic(), toFilterExpression(data.getExpressionType(), data.getSubString()));
+ }
+ }
+ if (runningInfo.getMqTable() != null) {
+ for (Map.Entry<org.apache.rocketmq.common.message.MessageQueue,
+ org.apache.rocketmq.remoting.protocol.body.ProcessQueueInfo> entry : runningInfo.getMqTable().entrySet()) {
+ org.apache.rocketmq.remoting.protocol.body.ProcessQueueInfo info = entry.getValue();
+ if (info == null) {
+ continue;
+ }
+ builder.addMessageQueueTable(MessageQueueItem.newBuilder()
+ .setMessageQueue(toMessageQueue(entry.getKey()))
+ .setProcessQueueInfo(apache.rocketmq.v2.ProcessQueueInfo.newBuilder()
+ .setCommitOffset(info.getCommitOffset())
+ .setCachedMsgMinOffset(info.getCachedMsgMinOffset())
+ .setCachedMsgMaxOffset(info.getCachedMsgMaxOffset())
+ .setCachedMsgCount(info.getCachedMsgCount())
+ .setCachedMsgSizeInMib(info.getCachedMsgSizeInMiB())
+ .setTransactionMsgMinOffset(info.getTransactionMsgMinOffset())
+ .setTransactionMsgMaxOffset(info.getTransactionMsgMaxOffset())
+ .setTransactionMsgCount(info.getTransactionMsgCount())
+ .setLocked(info.isLocked())
+ .setTryUnlockTimes(info.getTryUnlockTimes())
+ .setLastLockTimestamp(info.getLastLockTimestamp())
+ .setDropped(info.isDroped())
+ .setLastPullTimestamp(info.getLastPullTimestamp())
+ .setLastConsumeTimestamp(info.getLastConsumeTimestamp())
+ .build())
+ .build());
+ }
+ }
+ if (runningInfo.getStatusTable() != null) {
+ for (Map.Entry<String, org.apache.rocketmq.remoting.protocol.body.ConsumeStatus> entry :
+ runningInfo.getStatusTable().entrySet()) {
+ org.apache.rocketmq.remoting.protocol.body.ConsumeStatus status = entry.getValue();
+ if (status == null) {
+ continue;
+ }
+ builder.putConsumeStatusTable(entry.getKey(), apache.rocketmq.v2.ConsumeStatus.newBuilder()
+ .setReceiveRt(status.getPullRT())
+ .setReceiveTps(status.getPullTPS())
+ .setConsumeRt(status.getConsumeRT())
+ .setConsumeOkTps(status.getConsumeOKTPS())
+ .setConsumeFailedTps(status.getConsumeFailedTPS())
+ .setConsumeFailedMsgs(status.getConsumeFailedMsgs())
+ .build());
+ }
+ }
+ return builder.build();
+ }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptor.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptor.java
new file mode 100644
index 0000000000..9f4d705283
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptor.java
@@ -0,0 +1,277 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.proxy.grpc.admin;
+
+import io.grpc.Grpc;
+import io.grpc.Metadata;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+import java.net.InetSocketAddress;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.rocketmq.auth.authentication.context.DefaultAuthenticationContext;
+import org.apache.rocketmq.auth.authentication.exception.AuthenticationException;
+import org.apache.rocketmq.auth.authentication.factory.AuthenticationFactory;
+import org.apache.rocketmq.auth.authentication.model.User;
+import org.apache.rocketmq.auth.authorization.AuthorizationEvaluator;
+import org.apache.rocketmq.auth.authentication.AuthenticationEvaluator;
+import org.apache.rocketmq.auth.authorization.context.DefaultAuthorizationContext;
+import org.apache.rocketmq.auth.authorization.exception.AuthorizationException;
+import org.apache.rocketmq.auth.authorization.factory.AuthorizationFactory;
+import org.apache.rocketmq.auth.authorization.model.Resource;
+import org.apache.rocketmq.auth.config.AuthConfig;
+import org.apache.rocketmq.common.action.Action;
+import org.apache.rocketmq.common.constant.LoggerName;
+import org.apache.rocketmq.common.resource.ResourcePattern;
+import org.apache.rocketmq.common.resource.ResourceType;
+import org.apache.rocketmq.logging.org.slf4j.Logger;
+import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.ProxyConfig;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+
+/**
+ * authorization interceptor for the dedicated Proxy Admin gRPC server.
+ *
+ * <p>Every admin RPC is bound to a dedicated ACL 2.0 resource under the
+ * {@code proxy.admin.*} namespace with a distinct action, giving true
+ * read-only / high-privilege isolation on top of the standard ACL 2.0 policy
+ * engine:
+ *
+ * <pre>
+ * proxy.admin.client ListConsumerConnection / ListSubscription / DescribeSubscription /
+ * DescribeGroupAccumulation / GetConsumerRunningInfo / QueryTimeSpan (GET/LIST)
+ * proxy.admin.config ChangeLogLevel (UPDATE)
+ * proxy.admin.connection PrintThreadStackTrace / VerifyMessage (UPDATE, high privilege)
+ * proxy.admin.route GetTopicRoute (GET)
+ * proxy.admin.ops broker-facing operations served by the Admin service
+ * (GET/LIST for queries; UPDATE/DELETE/PUB for mutations)
+ * </pre>
+ *
+ * <p>Resources are modeled as {@code CLUSTER}-typed literal resources
+ * (resource key {@code cluster:proxy.admin.<module>}) because the ACL 2.0
+ * resource model only defines cluster/namespace/topic/group types; a
+ * cluster-typed literal gives exact least-privilege matching without
+ * colliding with real cluster names.
+ *
+ * <p>Behavior modes:
+ * <ul>
+ * <li>Cluster auth disabled and {@code grpcAdminServerAuthEnable=false}: the
+ * admin surface is open (same semantics as the data plane).</li>
+ * <li>Cluster auth enabled: requests are authenticated from the standard
+ * {@code Authorization} gRPC metadata and authorized against the
+ * per-method {@code proxy.admin.*} resource, exactly like the data
+ * plane does for topic/group resources.</li>
+ * <li>{@code grpcAdminServerAuthEnable=true}: fail-closed mode. The admin
+ * surface must be able to both authenticate the caller and enforce the
+ * per-method {@code proxy.admin.*} ACL, so it requires the cluster-wide
+ * authentication AND authorization switches to be on. If either is off
+ * every request is refused rather than served without a real permission
+ * check: the authorization evaluator is itself gated by the cluster
+ * authorization switch, so with authorization off it would silently pass
+ * every call (including the destructive ones).</li>
+ * </ul>
+ */
+public class ProxyAdminAuthInterceptor implements ServerInterceptor {
+
+ private static final Logger log = LoggerFactory.getLogger(LoggerName.PROXY_LOGGER_NAME);
+ private static final Logger logAudit = LoggerFactory.getLogger(LoggerName.ROCKETMQ_AUTH_AUDIT_LOGGER_NAME);
+
+ public static final String RESOURCE_CLIENT = "proxy.admin.client";
+ public static final String RESOURCE_CONFIG = "proxy.admin.config";
+ public static final String RESOURCE_CONNECTION = "proxy.admin.connection";
+ public static final String RESOURCE_ROUTE = "proxy.admin.route";
+ public static final String RESOURCE_OPS = "proxy.admin.ops";
+
+ private static final Map<String, ResourceAction> METHOD_PERMISSIONS = new HashMap<>();
+
+ static {
+ // Admin service (rocketmq-apis v2 admin.proto) served on the dedicated admin server.
+ METHOD_PERMISSIONS.put("GetProxyRuntimeStats", new ResourceAction(RESOURCE_OPS, Action.GET));
+ METHOD_PERMISSIONS.put("GetTopicRoute", new ResourceAction(RESOURCE_ROUTE, Action.GET));
+ METHOD_PERMISSIONS.put("DescribeTopicStatus", new ResourceAction(RESOURCE_OPS, Action.GET));
+ METHOD_PERMISSIONS.put("ListSubscription", new ResourceAction(RESOURCE_CLIENT, Action.LIST));
+ METHOD_PERMISSIONS.put("DescribeSubscription", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+ METHOD_PERMISSIONS.put("ListConsumerConnection", new ResourceAction(RESOURCE_CLIENT, Action.LIST));
+ METHOD_PERMISSIONS.put("DescribeGroupAccumulation", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+ METHOD_PERMISSIONS.put("GetConsumerRunningInfo", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+ METHOD_PERMISSIONS.put("QueryTimeSpan", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+ METHOD_PERMISSIONS.put("QueryMessage", new ResourceAction(RESOURCE_OPS, Action.GET));
+ METHOD_PERMISSIONS.put("ChangeLogLevel", new ResourceAction(RESOURCE_CONFIG, Action.UPDATE));
+ // High-privilege mutations: strictly separated from the read-only actions above.
+ METHOD_PERMISSIONS.put("DeleteSubscription", new ResourceAction(RESOURCE_OPS, Action.DELETE));
+ METHOD_PERMISSIONS.put("ResetGroupOffset", new ResourceAction(RESOURCE_OPS, Action.UPDATE));
+ METHOD_PERMISSIONS.put("AdminSendMessage", new ResourceAction(RESOURCE_OPS, Action.PUB));
+ METHOD_PERMISSIONS.put("PrintThreadStackTrace", new ResourceAction(RESOURCE_CONNECTION, Action.UPDATE));
+ METHOD_PERMISSIONS.put("VerifyMessage", new ResourceAction(RESOURCE_CONNECTION, Action.UPDATE));
+ }
+
+ private final AuthConfig authConfig;
+ private final AuthenticationEvaluator authenticationEvaluator;
+ private final AuthorizationEvaluator authorizationEvaluator;
+
+ public ProxyAdminAuthInterceptor(AuthConfig authConfig, MessagingProcessor messagingProcessor) {
+ this.authConfig = authConfig;
+ this.authenticationEvaluator = AuthenticationFactory.getEvaluator(authConfig,
+ messagingProcessor::getMetadataService);
+ this.authorizationEvaluator = AuthorizationFactory.getEvaluator(authConfig,
+ messagingProcessor::getMetadataService);
+ }
+
+ @Override
+ public <R, W> ServerCall.Listener<R> interceptCall(ServerCall<R, W> call, Metadata headers,
+ ServerCallHandler<R, W> next) {
+ String method = call.getMethodDescriptor().getBareMethodName();
+ try {
+ ProxyConfig proxyConfig = ConfigurationManager.getProxyConfig();
+ boolean requireAuth = proxyConfig != null && proxyConfig.isGrpcAdminServerAuthEnable();
+ boolean authnEnabled = authConfig != null && authConfig.isAuthenticationEnabled();
+ boolean authzEnabled = authConfig != null && authConfig.isAuthorizationEnabled();
+
+ // Open mode: identical semantics to the data plane when cluster auth is off.
+ if (!requireAuth && !authnEnabled && !authzEnabled) {
+ return next.startCall(call, headers);
+ }
+
+ // Fail-closed: credentials are demanded but cannot be verified at all.
+ if (requireAuth && !authnEnabled) {
+ call.close(Status.UNAUTHENTICATED.withDescription(
+ "grpcAdminServerAuthEnable is on but cluster authenticationEnabled is off; "
+ + "enable authentication before using the admin surface in fail-closed mode"), new Metadata());
+ return noopListener();
+ }
+
+ // Fail-closed: the admin surface is locked down, but the ACL engine that enforces the
+ // per-method proxy.admin.* permission is gated by the cluster authorization switch.
+ // With that switch off the authorization evaluator returns without checking anything,
+ // so requiring auth here without authorization on would let any authenticated identity
+ // reach every admin RPC, including the destructive ones. Refuse instead of serving
+ // an unauthorized call.
+ if (requireAuth && !authzEnabled) {
+ call.close(Status.FAILED_PRECONDITION.withDescription(
+ "grpcAdminServerAuthEnable is on but cluster authorizationEnabled is off; "
+ + "enable authorization so the proxy.admin.* ACL can be enforced before "
+ + "using the admin surface in fail-closed mode"), new Metadata());
+ return noopListener();
+ }
+
+ String username = null;
+ if (authnEnabled || requireAuth) {
+ DefaultAuthenticationContext authenticationContext = buildAuthenticationContext(call, headers);
+ username = authenticationContext.getUsername();
+ if (StringUtils.isBlank(username)) {
+ if (requireAuth) {
+ call.close(Status.UNAUTHENTICATED.withDescription("missing credentials for proxy admin"),
+ new Metadata());
+ return noopListener();
+ }
+ authenticationEvaluator.evaluate(authenticationContext);
+ } else {
+ authenticationEvaluator.evaluate(authenticationContext);
+ }
+ }
+
+ ResourceAction resourceAction = METHOD_PERMISSIONS.get(method);
+ // After the fail-closed guards above, requireAuth implies authzEnabled, so the ACL is
+ // enforced exactly when the cluster authorization switch is on; there is no path where
+ // requireAuth alone would ask the (globally gated) evaluator to enforce and be ignored.
+ if (resourceAction != null && authzEnabled) {
+ if (StringUtils.isBlank(username)) {
+ call.close(Status.UNAUTHENTICATED.withDescription("missing credentials for proxy admin"),
+ new Metadata());
+ return noopListener();
+ }
+ DefaultAuthorizationContext authorizationContext = DefaultAuthorizationContext.of(
+ User.of(username),
+ Resource.of(ResourceType.CLUSTER, resourceAction.resource, ResourcePattern.LITERAL),
+ resourceAction.action,
+ resolveSourceIp(call));
+ authorizationContext.setRpcCode(call.getMethodDescriptor().getFullMethodName());
+ authorizationEvaluator.evaluate(Collections.singletonList(authorizationContext));
+ }
+
+ logAudit.info("[PROXY-ADMIN-AUDIT] subject = {} method = {} resource = {} action = {} sourceIp = {}",
+ StringUtils.isBlank(username) ? "anonymous" : username, method,
+ resourceAction == null ? "unmapped" : resourceAction.resource,
+ resourceAction == null ? "unknown" : resourceAction.action.getName(),
+ resolveSourceIp(call));
+ return next.startCall(call, headers);
+ } catch (AuthenticationException e) {
+ log.warn("admin authentication failed. method:{}, cause:{}", method, e.getMessage());
+ call.close(Status.UNAUTHENTICATED.withDescription(e.getMessage()), new Metadata());
+ return noopListener();
+ } catch (AuthorizationException e) {
+ log.warn("admin authorization denied. method:{}, cause:{}", method, e.getMessage());
+ call.close(Status.PERMISSION_DENIED.withDescription(e.getMessage()), new Metadata());
+ return noopListener();
+ } catch (Throwable t) {
+ log.error("admin auth interceptor error. method:{}", method, t);
+ call.close(Status.INTERNAL.withDescription(t.getMessage()), new Metadata());
+ return noopListener();
+ }
+ }
+
+ private <R, W> DefaultAuthenticationContext buildAuthenticationContext(ServerCall<R, W> call,
+ Metadata headers) {
+ // The builder only uses the message for its descriptor name; pass the shared
+ // Status default instance and overwrite rpcCode with the real admin method.
+ Object context = AuthenticationFactory.newContext(authConfig, headers,
+ apache.rocketmq.v2.Status.getDefaultInstance());
+ if (!(context instanceof DefaultAuthenticationContext)) {
+ throw new AuthenticationException("unsupported authentication context type for proxy admin");
+ }
+ DefaultAuthenticationContext authenticationContext = (DefaultAuthenticationContext) context;
+ authenticationContext.setRpcCode(call.getMethodDescriptor().getFullMethodName());
+ return authenticationContext;
+ }
+
+ private static <R, W> String resolveSourceIp(ServerCall<R, W> call) {
+ try {
+ InetSocketAddress remoteAddress = (InetSocketAddress) call.getAttributes()
+ .get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
+ if (remoteAddress != null && remoteAddress.getAddress() != null) {
+ return remoteAddress.getAddress().getHostAddress();
+ }
+ } catch (Throwable ignore) {
+ // best-effort only
+ }
+ return "";
+ }
+
+ private static <R> ServerCall.Listener<R> noopListener() {
+ return new ServerCall.Listener<R>() {
+ };
+ }
+
+ static ResourceAction resolveResourceAction(String method) {
+ return METHOD_PERMISSIONS.get(method);
+ }
+
+ static final class ResourceAction {
+ final String resource;
+ final Action action;
+
+ ResourceAction(String resource, Action action) {
+ this.resource = resource;
+ this.action = action;
+ }
+ }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminForwarder.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminForwarder.java
new file mode 100644
index 0000000000..7b806791a2
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminForwarder.java
@@ -0,0 +1,356 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.AdminGrpc;
+import io.grpc.Context;
+import io.grpc.ManagedChannel;
+import io.grpc.Metadata;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory;
+import io.grpc.stub.MetadataUtils;
+import io.grpc.stub.StreamObserver;
+import io.netty.channel.Channel;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.rocketmq.broker.client.ClientChannelInfo;
+import org.apache.rocketmq.broker.client.ConsumerManager;
+import org.apache.rocketmq.common.constant.GrpcConstants;
+import org.apache.rocketmq.common.constant.LoggerName;
+import org.apache.rocketmq.common.utils.StartAndShutdown;
+import org.apache.rocketmq.logging.org.slf4j.Logger;
+import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
+import org.apache.rocketmq.proxy.common.channel.ChannelHelper;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.ProxyConfig;
+import org.apache.rocketmq.proxy.processor.channel.RemoteChannel;
+import org.apache.rocketmq.proxy.service.ServiceManager;
+import org.apache.rocketmq.remoting.common.TlsMode;
+import org.apache.rocketmq.remoting.netty.TlsSystemConfig;
+
+/**
+ * Forwards a Proxy Admin RPC to the peer proxy that actually owns the target client.
+ *
+ * <p>A gRPC client keeps a single telemetry stream, so only the proxy holding that stream can ask
+ * the client for a thread dump, a verify-message run or its running info. {@code
+ * GrpcChannelManager#getChannel(clientId)} is therefore local-only and returns {@code null} for a
+ * client connected elsewhere. {@link ConsumerManager#findChannel(String, String)} on the other hand
+ * also sees clients registered on peer proxies: {@code HeartbeatSyncer} replicates them as
+ * {@link RemoteChannel} instances whose {@link RemoteChannel#getRemoteProxyIp()} is the owning
+ * proxy's {@code ProxyConfig.localServeAddr}. That is exactly the routing hint this class uses.
+ *
+ * <p><b>Peer admin port assumption.</b> The admin port is not published anywhere in the heartbeat
+ * sync payload ({@code HeartbeatSyncerData} carries {@code remotingListenPort} and
+ * {@code grpcServerPort}, not the admin port), so this class assumes the admin port is uniform
+ * across the cluster and dials the peer at {@code <remoteProxyIp>:<this proxy's own
+ * ProxyConfig.getGrpcAdminServerPort()>}. A cluster that runs heterogeneous admin ports would need
+ * the port added to the heartbeat sync record first.
+ *
+ * <p><b>TLS.</b> Every proxy gRPC server installs {@code ProxyAndTlsProtocolNegotiator}. In the
+ * default configuration ({@code tlsTestModeEnable=true}) each JVM generates a fresh
+ * {@code SelfSignedCertificate}, so no peer can possibly validate it; because
+ * {@code TlsSystemConfig.tlsMode} defaults to {@code permissive} the plaintext handshake is
+ * accepted and used. Otherwise an {@link InsecureTrustManagerFactory}-based client SslContext is
+ * used: there is no cluster-wide CA distribution for service-to-service calls, and the peer is
+ * addressed by bare IP, which no sane certificate carries as a SAN.
+ *
+ * <p><b>Auth.</b> {@code ProxyAdminAuthInterceptor} derives the caller identity from the inbound
+ * {@code authorization} + {@code x-mq-date-time} metadata and there is no service-to-service
+ * identity in the open-source proxy. A forwarded call therefore impersonates the original caller:
+ * every inbound metadata key is copied verbatim onto the outbound call. The caller must already be
+ * authorized for the admin resource on the entry proxy, and the peer re-runs the very same
+ * authentication/authorization evaluation against those copied credentials.
+ *
+ * <p><b>Loop prevention.</b> A forwarded call carries {@link GrpcConstants#ADMIN_FORWARDED}. The
+ * receiving proxy detects it with {@link #isInboundForwarded()} and must serve the call locally
+ * instead of forwarding again, which also stops a two-proxy ping-pong when both sides see the
+ * client as remote.
+ */
+public class ProxyAdminForwarder implements StartAndShutdown {
+
+ private static final Logger log = LoggerFactory.getLogger(LoggerName.PROXY_LOGGER_NAME);
+
+ /**
+ * Wire value of {@link GrpcConstants#ADMIN_FORWARDED}.
+ */
+ private static final String FORWARDED_VALUE = "true";
+
+ /**
+ * gRPC reserves the {@code grpc-} prefix; {@link Metadata.Key#of} rejects such names.
+ */
+ private static final String RESERVED_KEY_PREFIX = "grpc-";
+
+ private static final long SHUTDOWN_GRACE_SECONDS = 5L;
+
+ private final ServiceManager serviceManager;
+
+ /**
+ * One channel + stub per remote proxy address ({@code <ip>:<adminPort>}). Channels are created
+ * lazily and are long-lived: a proxy set is small and stable, so re-handshaking per RPC would
+ * only add latency.
+ */
+ private final ConcurrentMap<String, PeerConnection> peerConnections = new ConcurrentHashMap<>();
+
+ public ProxyAdminForwarder(ServiceManager serviceManager) {
+ this.serviceManager = serviceManager;
+ }
+
+ /**
+ * True when this call already arrived forwarded from a peer proxy; such a call must never be
+ * forwarded again.
+ */
+ public boolean isInboundForwarded() {
+ Metadata inbound = GrpcConstants.METADATA.get(Context.current());
+ if (inbound == null) {
+ return false;
+ }
+ return FORWARDED_VALUE.equalsIgnoreCase(inbound.get(GrpcConstants.ADMIN_FORWARDED));
+ }
+
+ /**
+ * If the client identified by (group, clientId) is registered on another proxy, forward the RPC
+ * there and return true — the caller must then return immediately and let the peer own the
+ * observer. Returns false when the client is local, unknown, or forwarding is not possible, so
+ * the caller proceeds with its normal local handling.
+ *
+ * <p>This method never throws. Every "cannot forward" condition degrades to {@code false}; if
+ * the decision to forward was already taken and the outbound call then fails, the failure is
+ * propagated to {@code responseObserver#onError} and {@code true} is returned — see
+ * {@link #invokeOnPeer}.
+ *
+ * @param group consumer group the client is registered under
+ * @param clientId client id, as reported by the client itself
+ * @param responseObserver the observer of the inbound admin RPC; ownership moves to the peer
+ * when this method returns true
+ * @param invocation performs the actual unary call on the peer's stub, e.g.
+ * {@code (stub, observer) -> stub.printThreadStackTrace(request, observer)}
+ */
+ public <T> boolean forwardIfRemote(String group, String clientId,
+ StreamObserver<T> responseObserver,
+ BiConsumer<AdminGrpc.AdminStub, StreamObserver<T>> invocation) {
+ if (StringUtils.isBlank(clientId)) {
+ return false;
+ }
+
+ // Loop guard first: a forwarded call must be answered locally even if this proxy still
+ // sees the client as remote (stale heartbeat sync), otherwise the two proxies bounce the
+ // call until it dies on a deadline.
+ if (isInboundForwarded()) {
+ log.info("admin call already forwarded by a peer, serving locally. group:{}, clientId:{}",
+ group, clientId);
+ return false;
+ }
+
+ ProxyConfig proxyConfig = ConfigurationManager.getProxyConfig();
+ if (proxyConfig == null) {
+ log.warn("proxy config is not initialized, cannot forward. group:{}, clientId:{}", group, clientId);
+ return false;
+ }
+ Integer adminPort = proxyConfig.getGrpcAdminServerPort();
+ if (adminPort == null || adminPort <= 0 || !proxyConfig.isGrpcAdminServerEnable()) {
+ log.warn("proxy admin server is not enabled on this cluster, cannot forward. "
+ + "group:{}, clientId:{}, adminPort:{}", group, clientId, adminPort);
+ return false;
+ }
+
+ ClientChannelInfo channelInfo = findClientChannel(group, clientId);
+ if (channelInfo == null) {
+ return false;
+ }
+ Channel channel = channelInfo.getChannel();
+ if (!ChannelHelper.isRemote(channel)) {
+ // A locally connected client: the caller can reach it through its own channel manager.
+ return false;
+ }
+ RemoteChannel remoteChannel = (RemoteChannel) channel;
+ String remoteProxyIp = remoteChannel.getRemoteProxyIp();
+ if (StringUtils.isBlank(remoteProxyIp)) {
+ log.warn("remote channel carries no owning proxy address, cannot forward. "
+ + "group:{}, clientId:{}, channel:{}", group, clientId, remoteChannel);
+ return false;
+ }
+ // Self-forward guard: RemoteChannel.remoteProxyIp is the peer's localServeAddr, so a plain
+ // string comparison detects "this proxy" and prevents a forward to ourselves.
+ if (StringUtils.equals(remoteProxyIp, proxyConfig.getLocalServeAddr())) {
+ log.info("client is owned by this proxy, serving locally. group:{}, clientId:{}", group, clientId);
+ return false;
+ }
+
+ return invokeOnPeer(remoteProxyIp, adminPort, group, clientId, responseObserver, invocation);
+ }
+
+ /**
+ * Runs {@code invocation} against the peer stub and hands the inbound observer to the peer's
+ * response stream.
+ *
+ * <p>Error translation: this class only holds the caller's {@code StreamObserver}, not the concrete
+ * response builder, so it cannot synthesize a business-level {@code Status} message inside the
+ * response body. A failed forward is therefore propagated as a gRPC {@code onError} carrying the
+ * peer's status (or {@code Status.INTERNAL} when the call could not even be issued), and
+ * {@code true} is returned so the caller stops and lets that error reach the client. Callers
+ * that want an in-band error instead should pre-check with {@code isInboundForwarded()} /
+ * channel locality themselves.
+ */
+ private <T> boolean invokeOnPeer(String remoteProxyIp, int adminPort, String group, String clientId,
+ StreamObserver<T> responseObserver,
+ BiConsumer<AdminGrpc.AdminStub, StreamObserver<T>> invocation) {
+ String target = remoteProxyIp + ":" + adminPort;
+ try {
+ PeerConnection peer = this.peerConnections.computeIfAbsent(target,
+ key -> createPeerConnection(remoteProxyIp, adminPort));
+ AdminGrpc.AdminStub stub = peer.stub.withInterceptors(
+ MetadataUtils.newAttachHeadersInterceptor(buildOutboundMetadata()));
+ log.info("forwarding admin call to peer proxy. target:{}, group:{}, clientId:{}",
+ target, group, clientId);
+ invocation.accept(stub, responseObserver);
+ return true;
+ } catch (Throwable t) {
+ log.error("forward admin call to peer proxy failed. target:{}, group:{}, clientId:{}",
+ target, group, clientId, t);
+ responseObserver.onError(Status.INTERNAL
+ .withDescription("forward admin call to peer proxy " + target + " failed: " + t.getMessage())
+ .withCause(t)
+ .asRuntimeException());
+ return true;
+ }
+ }
+
+ private ClientChannelInfo findClientChannel(String group, String clientId) {
+ ConsumerManager consumerManager = this.serviceManager == null
+ ? null : this.serviceManager.getConsumerManager();
+ if (consumerManager == null) {
+ return null;
+ }
+ try {
+ return consumerManager.findChannel(group, clientId);
+ } catch (Throwable t) {
+ log.warn("find client channel failed. group:{}, clientId:{}", group, clientId, t);
+ return null;
+ }
+ }
+
+ /**
+ * Builds the metadata for the outbound call: a verbatim copy of the inbound metadata (so the
+ * peer can authenticate/authorize the original caller, see the class javadoc) plus the
+ * loop-prevention marker.
+ */
+ private Metadata buildOutboundMetadata() {
+ Metadata outbound = new Metadata();
+ Metadata inbound = GrpcConstants.METADATA.get(Context.current());
+ if (inbound != null) {
+ for (String name : inbound.keys()) {
+ if (!isCopyable(name)) {
+ continue;
+ }
+ try {
+ Metadata.Key<String> key = Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER);
+ for (String value : inbound.getAll(key)) {
+ outbound.put(key, value);
+ }
+ } catch (Throwable t) {
+ // Defensive: an exotic inbound key must not abort the whole forward.
+ log.warn("skip inbound metadata key that cannot be forwarded. key:{}", name, t);
+ }
+ }
+ }
+ // discardAll first: Metadata#put appends, and a stale marker from the inbound copy would
+ // otherwise leave the header duplicated.
+ outbound.discardAll(GrpcConstants.ADMIN_FORWARDED);
+ outbound.put(GrpcConstants.ADMIN_FORWARDED, FORWARDED_VALUE);
+ return outbound;
+ }
+
+ private static boolean isCopyable(String name) {
+ if (StringUtils.isBlank(name)) {
+ return false;
+ }
+ // Binary headers cannot be read back through ASCII_STRING_MARSHALLER; grpc-* names are
+ // reserved and rejected by Metadata.Key#of.
+ return !name.endsWith(Metadata.BINARY_HEADER_SUFFIX) && !name.startsWith(RESERVED_KEY_PREFIX);
+ }
+
+ private PeerConnection createPeerConnection(String host, int port) {
+ NettyChannelBuilder builder = NettyChannelBuilder.forAddress(host, port);
+ if (usePlaintext()) {
+ builder.usePlaintext();
+ } else {
+ builder.sslContext(insecureClientSslContext());
+ }
+ ManagedChannel channel = builder.build();
+ log.info("created proxy admin client channel to peer proxy. target:{}:{}", host, port);
+ return new PeerConnection(channel, AdminGrpc.newStub(channel));
+ }
+
+ private static boolean usePlaintext() {
+ // ENFORCING is the only tlsMode in which the peer's negotiator rejects a plaintext
+ // handshake, so it always wins over tlsTestModeEnable.
+ if (TlsMode.ENFORCING.equals(TlsSystemConfig.tlsMode)) {
+ return false;
+ }
+ ProxyConfig proxyConfig = ConfigurationManager.getProxyConfig();
+ // tlsTestModeEnable=true mints a fresh SelfSignedCertificate per JVM; no peer can validate
+ // it, and permissive mode (the default) accepts plaintext, so skip TLS entirely.
+ return proxyConfig == null || proxyConfig.isTlsTestModeEnable();
+ }
+
+ private static SslContext insecureClientSslContext() {
+ try {
+ return GrpcSslContexts.configure(
+ GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE))
+ .build();
+ } catch (Exception e) {
+ throw new IllegalStateException("build proxy admin client SslContext failed", e);
+ }
+ }
+
+ @Override
+ public void start() throws Exception {
+ // Peer connections are created lazily on first forward; nothing to warm up.
+ }
+
+ @Override
+ public void shutdown() throws Exception {
+ for (PeerConnection peer : this.peerConnections.values()) {
+ try {
+ peer.channel.shutdown();
+ if (!peer.channel.awaitTermination(SHUTDOWN_GRACE_SECONDS, TimeUnit.SECONDS)) {
+ peer.channel.shutdownNow();
+ }
+ } catch (Throwable t) {
+ log.warn("shutdown proxy admin client channel failed. channel:{}", peer.channel, t);
+ peer.channel.shutdownNow();
+ }
+ }
+ this.peerConnections.clear();
+ }
+
+ private static final class PeerConnection {
+ private final ManagedChannel channel;
+ private final AdminGrpc.AdminStub stub;
+
+ private PeerConnection(ManagedChannel channel, AdminGrpc.AdminStub stub) {
+ this.channel = channel;
+ this.stub = stub;
+ }
+ }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcService.java
new file mode 100644
index 0000000000..09b0196f44
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcService.java
@@ -0,0 +1,1735 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.AdminGrpc;
+import apache.rocketmq.v2.AdminSendMessageRequest;
+import apache.rocketmq.v2.AdminSendMessageResponse;
+import apache.rocketmq.v2.ChangeLogLevelRequest;
+import apache.rocketmq.v2.ChangeLogLevelResponse;
+import apache.rocketmq.v2.ClientInfo;
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.ConsumerRunningInfo;
+import apache.rocketmq.v2.DeleteSubscriptionRequest;
+import apache.rocketmq.v2.DeleteSubscriptionResponse;
+import apache.rocketmq.v2.DescribeGroupAccumulationRequest;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse;
+import apache.rocketmq.v2.DescribeSubscriptionRequest;
+import apache.rocketmq.v2.DescribeSubscriptionResponse;
+import apache.rocketmq.v2.DescribeTopicStatusRequest;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.GetConsumerRunningInfoRequest;
+import apache.rocketmq.v2.GetConsumerRunningInfoResponse;
+import apache.rocketmq.v2.GetProxyRuntimeStatsRequest;
+import apache.rocketmq.v2.GetProxyRuntimeStatsResponse;
+import apache.rocketmq.v2.GetTopicRouteRequest;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.ListConsumerConnectionRequest;
+import apache.rocketmq.v2.ListConsumerConnectionResponse;
+import apache.rocketmq.v2.ListMessageRequest;
+import apache.rocketmq.v2.ListMessageResponse;
+import apache.rocketmq.v2.ListSubscriptionRequest;
+import apache.rocketmq.v2.ListSubscriptionResponse;
+import apache.rocketmq.v2.PrintThreadStackTraceRequest;
+import apache.rocketmq.v2.PrintThreadStackTraceResponse;
+import apache.rocketmq.v2.QueryTimeSpanRequest;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import apache.rocketmq.v2.ResetGroupOffsetRequest;
+import apache.rocketmq.v2.ResetGroupOffsetResponse;
+import apache.rocketmq.v2.Resource;
+import apache.rocketmq.v2.Status;
+import apache.rocketmq.v2.SubscriptionInfo;
+import apache.rocketmq.v2.SystemProperties;
+import apache.rocketmq.v2.VerifyMessageRequest;
+import apache.rocketmq.v2.VerifyMessageResponse;
+import com.google.protobuf.Timestamp;
+import com.google.protobuf.util.Timestamps;
+import io.grpc.stub.StreamObserver;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import org.apache.rocketmq.broker.client.ClientChannelInfo;
+import org.apache.rocketmq.broker.client.ConsumerGroupInfo;
+import org.apache.rocketmq.broker.client.ConsumerManager;
+import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MQVersion;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.ThreadFactoryImpl;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.message.MessageAccessor;
+import org.apache.rocketmq.common.message.MessageConst;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.common.utils.StartAndShutdown;
+import org.apache.rocketmq.common.utils.ThreadUtils;
+import org.apache.rocketmq.logging.org.slf4j.Logger;
+import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
+import org.apache.rocketmq.proxy.common.ProxyContext;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcChannelManager;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcClientChannel;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcConverter;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcProxyException;
+import org.apache.rocketmq.proxy.grpc.v2.common.ResponseBuilder;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+import org.apache.rocketmq.proxy.service.ServiceManager;
+import org.apache.rocketmq.proxy.service.admin.AdminService;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayRequest;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
+import org.apache.rocketmq.proxy.service.route.AddressableMessageQueue;
+import org.apache.rocketmq.proxy.service.route.MessageQueueView;
+import org.apache.rocketmq.remoting.protocol.RequestCode;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.body.CMResult;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.ConsumeMessageDirectlyResultRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SimpleSubscriptionData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+
+/**
+ * Proxy Admin gRPC service (control plane).
+ *
+ * <p>Design rules this class follows:
+ * <ul>
+ * <li><b>Never block the gRPC executor.</b> Every broker hop goes through the asynchronous
+ * {@link AdminService} gateway and is fanned out to all relevant brokers concurrently.</li>
+ * <li><b>Cluster-wide view.</b> Subscription and connection data is read from the brokers (which
+ * see consumers registered through every proxy) rather than from this proxy's local channel
+ * table, and consumers owned by a peer proxy are reached by forwarding the whole RPC.</li>
+ * <li><b>Honest responses.</b> A field the open-source proxy genuinely cannot supply is left
+ * unset and explained in the status message, instead of being filled with a plausible value.</li>
+ * <li><b>Graded errors.</b> Failures are mapped through {@link ResponseBuilder#buildStatus(Throwable)}
+ * so callers can tell "topic not found" from "internal error".</li>
+ * </ul>
+ *
+ * <p>The translation between broker wire types and the v2 contract lives in
+ * {@link AdminModelConverter}; whole-message conversion reuses the data plane's
+ * {@link GrpcConverter} instead of duplicating it.
+ */
+public class ProxyAdminGrpcService extends AdminGrpc.AdminImplBase implements StartAndShutdown {
+
+ private static final Logger log = LoggerFactory.getLogger(ProxyAdminGrpcService.class);
+
+ /**
+ * Bounds every wait this service performs that is not already bounded by the callee: the answer
+ * to a relayed telemetry command, and each broker hop of a fan-out. The gRPC nonce sweeper and
+ * the remoting timeout cover the healthy paths, but a hop that never completes at all (an
+ * unwritable channel, a saturated blocking-call pool) would otherwise leave the RPC hanging
+ * forever with no response at all.
+ */
+ private final ScheduledExecutorService adminTimeoutScheduler =
+ ThreadUtils.newSingleThreadScheduledExecutor(new ThreadFactoryImpl("ProxyAdminTimeout_", true));
+
+ /**
+ * How much longer than the broker timeout a single hop is allowed to take. The broker call
+ * carries its own timeout, so this only catches a gateway future that never completes; the slack
+ * keeps a genuine broker timeout reporting its own cause instead of this generic deadline.
+ */
+ private static final long BROKER_CALL_DEADLINE_SLACK_MILLIS = 1000L;
+
+ private static final String ADMIN_SEND_PRODUCER_GROUP = "ADMIN_SEND_PRODUCER_GROUP";
+ private static final int DEFAULT_MAX_MESSAGE_NUMS = 32;
+
+ private final ServiceManager serviceManager;
+ private final MessagingProcessor messagingProcessor;
+ private final GrpcChannelManager grpcChannelManager;
+ private final GrpcClientSettingsManager grpcClientSettingsManager;
+ private final ProxyAdminForwarder forwarder;
+
+ public ProxyAdminGrpcService(ServiceManager serviceManager, MessagingProcessor messagingProcessor,
+ GrpcChannelManager grpcChannelManager, GrpcClientSettingsManager grpcClientSettingsManager,
+ ProxyAdminForwarder forwarder) {
+ this.serviceManager = serviceManager;
+ this.messagingProcessor = messagingProcessor;
+ this.grpcChannelManager = grpcChannelManager;
+ this.grpcClientSettingsManager = grpcClientSettingsManager;
+ this.forwarder = forwarder;
+ }
+
+ // =========================================================================
+ // helpers
+ // =========================================================================
+
+ private AdminService admin() {
+ return this.serviceManager.getAdminService();
+ }
+
+ private long timeoutMillis() {
+ return ConfigurationManager.getProxyConfig().getGrpcAdminServerRequestTimeoutMillis();
+ }
+
+ private ProxyContext ctx() {
+ return ProxyContext.create();
+ }
+
+ private static Status ok() {
+ return AdminModelConverter.ok();
+ }
+
+ private static Status err(Throwable t) {
+ return ResponseBuilder.getInstance().buildStatus(t);
+ }
+
+ private static Status err(Code code, String message) {
+ return ResponseBuilder.getInstance().buildStatus(code, message);
+ }
+
+ /** Sends exactly one response. Splitting onNext/onCompleted across a try and a catch is what
+ * makes a StreamObserver throw IllegalStateException on a partially-succeeded call. */
+ private static <T> void respond(StreamObserver<T> observer, T response) {
+ observer.onNext(response);
+ observer.onCompleted();
+ }
+
+ /** Master broker address of every broker group in the cluster. */
+ private CompletableFuture<List<String>> allMasterBrokerAddrs() {
+ return admin().getBrokerClusterInfo(timeoutMillis()).thenApply(clusterInfo -> {
+ List<String> addrs = new ArrayList<>();
+ if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
+ return addrs;
+ }
+ for (BrokerData brokerData : clusterInfo.getBrokerAddrTable().values()) {
+ String master = brokerData.getBrokerAddrs() == null ? null
+ : brokerData.getBrokerAddrs().get(MixAll.MASTER_ID);
+ if (master != null && !master.isEmpty()) {
+ addrs.add(master);
+ }
+ }
+ return addrs;
+ });
+ }
+
+ /**
+ * Addresses of the brokers hosting a topic. Write operations must use the write selector: the
+ * read selector can omit brokers that only accept writes and vice versa.
+ */
+ private List<String> topicBrokerAddrs(String topic, boolean forWrite) throws Exception {
+ MessageQueueView view = serviceManager.getTopicRouteService().getAllMessageQueueView(ctx(), topic);
+ if (view == null) {
+ throw new IllegalStateException("topic route not found for " + topic);
+ }
+ List<AddressableMessageQueue> queues = forWrite
+ ? view.getWriteSelector().getQueues() : view.getReadSelector().getQueues();
+ if (queues == null || queues.isEmpty()) {
+ throw new IllegalStateException("no " + (forWrite ? "writable" : "readable") + " queue for topic " + topic);
+ }
+ Set<String> addrs = new LinkedHashSet<>();
+ for (AddressableMessageQueue queue : queues) {
+ if (queue.getBrokerAddr() != null && !queue.getBrokerAddr().isEmpty()) {
+ addrs.add(queue.getBrokerAddr());
+ }
+ }
+ if (addrs.isEmpty()) {
+ throw new IllegalStateException("broker address not found for topic " + topic);
+ }
+ return new ArrayList<>(addrs);
+ }
+
+ /**
+ * Runs a query against every broker concurrently and keeps the successful answers. A broker
+ * that fails does not fail the whole call — a partially available cluster should still return
+ * the data it has. Only when every broker fails is the first error propagated, so the caller
+ * reports a real cause instead of an empty success.
+ */
+ private <T> CompletableFuture<Map<String, T>> queryAllBrokers(Collection<String> brokerAddrs,
+ BiFunction<String, Long, CompletableFuture<T>> query) {
+ if (brokerAddrs == null || brokerAddrs.isEmpty()) {
+ return CompletableFuture.completedFuture(Collections.emptyMap());
+ }
+ long timeout = timeoutMillis();
+ Map<String, CompletableFuture<T>> pending = new LinkedHashMap<>();
+ List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
+ for (String brokerAddr : brokerAddrs) {
+ CompletableFuture<T> hop = withTimeout(query.apply(brokerAddr, timeout),
+ timeout + BROKER_CALL_DEADLINE_SLACK_MILLIS, "broker call to " + brokerAddr);
+ pending.put(brokerAddr, hop
+ .whenComplete((result, throwable) -> {
+ if (throwable != null) {
+ failures.add(throwable);
+ }
+ }));
+ }
+ return CompletableFuture.allOf(pending.values().toArray(new CompletableFuture[0]))
+ .handle((ignored, throwable) -> {
+ Map<String, T> succeeded = new LinkedHashMap<>();
+ for (Map.Entry<String, CompletableFuture<T>> entry : pending.entrySet()) {
+ CompletableFuture<T> future = entry.getValue();
+ if (!future.isCompletedExceptionally()) {
+ T result = future.getNow(null);
+ if (result != null) {
+ succeeded.put(entry.getKey(), result);
+ }
+ }
+ }
+ return succeeded;
+ })
+ .thenCompose(succeeded -> {
+ if (succeeded.isEmpty() && !failures.isEmpty()) {
+ CompletableFuture<Map<String, T>> failed = new CompletableFuture<>();
+ failed.completeExceptionally(failures.get(0));
+ return failed;
+ }
+ return CompletableFuture.completedFuture(succeeded);
+ });
+ }
+
+ private <T> CompletableFuture<T> withTimeout(CompletableFuture<T> future, long timeoutMillis,
+ String what) {
+ CompletableFuture<T> timeout = new CompletableFuture<>();
+ ScheduledFuture<?> timer = this.adminTimeoutScheduler.schedule(
+ () -> timeout.completeExceptionally(new IllegalStateException(
+ what + " timed out after " + timeoutMillis + "ms")),
+ timeoutMillis, TimeUnit.MILLISECONDS);
+ // one admin RPC fans out to every broker, so the timer is cancelled as soon as the answer
+ // arrives instead of leaving a dead task per hop queued until its deadline
+ return future.applyToEither(timeout, result -> result)
+ .whenComplete((result, throwable) -> timer.cancel(false));
+ }
+
+ private static Resource resource(String name) {
+ return Resource.newBuilder().setName(name == null ? "" : name).build();
+ }
+
+ /**
+ * Locates the client a client-targeted RPC is about. Returns the channel info when the client
+ * is known to this proxy's consumer manager (which, in cluster mode, also holds entries synced
+ * from peer proxies), or null.
+ */
+ private ClientChannelInfo findClientChannel(String group, String clientId) {
+ if (clientId == null || clientId.isEmpty()) {
+ return null;
+ }
+ ConsumerManager consumerManager = serviceManager.getConsumerManager();
+ if (consumerManager == null) {
+ return null;
+ }
+ if (group != null && !group.isEmpty()) {
+ ClientChannelInfo info = consumerManager.findChannel(group, clientId);
+ if (info != null) {
+ return info;
+ }
+ }
+ // the caller may not know the group; the local gRPC channel table is keyed by clientId alone
+ GrpcClientChannel channel = grpcChannelManager.getChannel(clientId);
+ if (channel == null) {
+ return null;
+ }
+ return new ClientChannelInfo(channel, clientId, null, 0);
+ }
+
+ // =========================================================================
+ // 1. ChangeLogLevel
+ // =========================================================================
+
+ @Override
+ public void changeLogLevel(ChangeLogLevelRequest request, StreamObserver<ChangeLogLevelResponse> responseObserver) {
+ String remark;
+ try {
+ org.apache.rocketmq.logging.org.slf4j.ILoggerFactory factory =
+ org.apache.rocketmq.logging.org.slf4j.LoggerFactory.getILoggerFactory();
+ if (!(factory instanceof org.apache.rocketmq.logging.ch.qos.logback.classic.LoggerContext)) {
+ remark = "unsupported logging backend, cannot change log level at runtime";
+ } else {
+ ChangeLogLevelRequest.Level level = request.getLevel();
+ // Level.TRACE is enum value 0, so proto3 cannot distinguish "caller asked for TRACE"
+ // from "caller sent an empty request". Honour the contract but make the consequence
+ // visible instead of silently switching a production proxy to the most verbose level.
+ if (level == ChangeLogLevelRequest.Level.TRACE) {
+ log.warn("changeLogLevel requested TRACE, which is also the proto3 default for an "
+ + "unset level; make sure this is intentional");
+ }
+ org.apache.rocketmq.logging.ch.qos.logback.classic.LoggerContext loggerContext =
+ (org.apache.rocketmq.logging.ch.qos.logback.classic.LoggerContext) factory;
+ org.apache.rocketmq.logging.ch.qos.logback.classic.Level logbackLevel =
+ org.apache.rocketmq.logging.ch.qos.logback.classic.Level.toLevel(level.name());
+ loggerContext.getLogger(
+ org.apache.rocketmq.logging.ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
+ .setLevel(logbackLevel);
+ remark = "log level changed to " + logbackLevel;
+ }
+ } catch (Throwable t) {
+ log.warn("changeLogLevel failed", t);
+ remark = "failed to change log level: " + t.getMessage();
+ }
+ respond(responseObserver, ChangeLogLevelResponse.newBuilder().setRemark(remark).build());
+ }
+
+ // =========================================================================
+ // 2. DescribeTopicStatus
+ // =========================================================================
+
+ @Override
+ public void describeTopicStatus(DescribeTopicStatusRequest request,
+ StreamObserver<DescribeTopicStatusResponse> responseObserver) {
+ String topic = request.getTopic().getName();
+ List<String> brokerAddrs;
+ try {
+ brokerAddrs = topicBrokerAddrs(topic, false);
+ } catch (Throwable t) {
+ respond(responseObserver, DescribeTopicStatusResponse.newBuilder().setStatus(err(t)).build());
+ return;
+ }
+ // a topic is normally configured identically on every broker hosting it, but asking only the
+ // first one hides a real inconsistency, so all of them are queried and compared
+ queryAllBrokers(brokerAddrs, (addr, timeout) -> admin().getTopicConfig(addr, topic, timeout))
+ .whenComplete((configs, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, DescribeTopicStatusResponse.newBuilder().setStatus(err(throwable)).build());
+ return;
+ }
+ try {
+ respond(responseObserver, AdminModelConverter.toTopicStatus(
+ mergeTopicConfigs(configs.values()), topic));
+ } catch (Throwable t) {
+ respond(responseObserver, DescribeTopicStatusResponse.newBuilder().setStatus(err(t)).build());
+ }
+ });
+ }
+
+ private static TopicConfig mergeTopicConfigs(Collection<TopicConfig> configs) {
+ TopicConfig merged = null;
+ TopicMessageType messageType = null;
+ int readQueueNums = 0;
+ int writeQueueNums = 0;
+ boolean consistent = true;
+ for (TopicConfig config : configs) {
+ if (config == null) {
+ continue;
+ }
+ if (merged == null) {
+ merged = config;
+ messageType = config.getTopicMessageType();
+ } else if (messageType != config.getTopicMessageType()) {
+ consistent = false;
+ }
+ readQueueNums += config.getReadQueueNums();
+ writeQueueNums += config.getWriteQueueNums();
+ }
+ if (merged == null) {
+ return null;
+ }
+ TopicConfig result = new TopicConfig();
+ result.setTopicName(merged.getTopicName());
+ result.setReadQueueNums(readQueueNums);
+ result.setWriteQueueNums(writeQueueNums);
+ result.setPerm(merged.getPerm());
+ // MIXED is the honest answer when brokers disagree, and TopicConfig maps it back to
+ // MESSAGE_TYPE_UNSPECIFIED in the response rather than pretending one broker is right
+ result.setAttributes(new HashMap<>());
+ result.getAttributes().put(org.apache.rocketmq.common.TopicAttributes.TOPIC_MESSAGE_TYPE_ATTRIBUTE.getName(),
+ (consistent ? messageType : TopicMessageType.MIXED).name());
+ return result;
+ }
+
+ // =========================================================================
+ // 3. ListSubscription
+ // =========================================================================
+
+ @Override
+ public void listSubscription(ListSubscriptionRequest request, StreamObserver<ListSubscriptionResponse> responseObserver) {
+ boolean hasTopic = request.hasTopic() && !request.getTopic().getName().isEmpty();
+ boolean hasGroup = request.hasGroup() && !request.getGroup().getName().isEmpty();
+ if (!hasTopic && !hasGroup) {
+ respond(responseObserver, ListSubscriptionResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "at least one of topic or group must be set")).build());
+ return;
+ }
+ String topic = hasTopic ? request.getTopic().getName() : null;
+ String group = hasGroup ? request.getGroup().getName() : null;
+ resolveGroups(topic, group)
+ .thenCompose(groups -> allMasterBrokerAddrs()
+ .thenCompose(brokers -> collectConnections(brokers, groups)))
+ .whenComplete((connections, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, ListSubscriptionResponse.newBuilder().setStatus(err(throwable)).build());
+ return;
+ }
+ try {
+ respond(responseObserver, buildListSubscriptionResponse(connections, topic));
+ } catch (Throwable t) {
+ respond(responseObserver, ListSubscriptionResponse.newBuilder().setStatus(err(t)).build());
+ }
+ });
+ }
+
+ private ListSubscriptionResponse buildListSubscriptionResponse(Map<String, ConsumerConnection> connections,
+ String topicFilter) {
+ ListSubscriptionResponse.Builder builder = ListSubscriptionResponse.newBuilder().setStatus(ok());
+ // a group reported by several brokers, or by several clients, describes the same
+ // subscription; keyed by group+topic+expression so the answer does not scale with
+ // the number of online consumers
+ Map<String, SubscriptionInfo> dedup = new LinkedHashMap<>();
+ String pullRetryPrefix = MixAll.RETRY_GROUP_TOPIC_PREFIX;
+ for (Map.Entry<String, ConsumerConnection> entry : connections.entrySet()) {
+ String group = entry.getKey();
+ ConsumerConnection connection = entry.getValue();
+ if (connection == null || connection.getSubscriptionTable() == null) {
+ continue;
+ }
+ boolean online = connection.getConnectionSet() != null && !connection.getConnectionSet().isEmpty();
+ apache.rocketmq.v2.MessageModel messageModel = AdminModelConverter.toMessageModel(connection.getMessageModel());
+ for (Map.Entry<String, org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData> sub :
+ connection.getSubscriptionTable().entrySet()) {
+ String subTopic = sub.getKey();
+ if (subTopic == null || subTopic.startsWith(pullRetryPrefix) || KeyBuilder.isPopRetryTopicV2(subTopic)) {
+ continue;
+ }
+ if (topicFilter != null && !topicFilter.equals(subTopic)) {
+ continue;
+ }
+ org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData data = sub.getValue();
+ SubscriptionInfo info = SubscriptionInfo.newBuilder()
+ .setGroup(resource(group))
+ .setTopic(resource(subTopic))
+ .setExpression(AdminModelConverter.toFilterExpression(
+ data == null ? null : data.getExpressionType(), data == null ? null : data.getSubString()))
+ .setOnline(online)
+ .setMessageModel(messageModel)
+ .build();
+ dedup.put(group + "\u0001" + subTopic, info);
+ }
+ }
+ for (SubscriptionInfo info : dedup.values()) {
+ builder.addSubscriptionInfo(info);
+ }
+ return builder.build();
+ }
+
+ /** Groups to inspect: the requested one, or every group consuming the requested topic. */
+ private CompletableFuture<Set<String>> resolveGroups(String topic, String group) {
+ if (group != null) {
+ return CompletableFuture.completedFuture(Collections.singleton(group));
+ }
+ return allMasterBrokerAddrs().thenCompose(brokers ->
+ queryAllBrokers(brokers, (addr, timeout) -> admin().queryTopicConsumeByWho(addr, topic, timeout))
+ .thenApply(byBroker -> {
+ Set<String> groups = new LinkedHashSet<>();
+ for (GroupList groupList : byBroker.values()) {
+ if (groupList != null && groupList.getGroupList() != null) {
+ groups.addAll(groupList.getGroupList());
+ }
+ }
+ return groups;
+ }));
+ }
+
+ /** Consumer connections of each group, merged across brokers. */
+ private CompletableFuture<Map<String, ConsumerConnection>> collectConnections(List<String> brokers,
+ Collection<String> groups) {
+ List<CompletableFuture<Void>> tasks = new ArrayList<>();
+ Map<String, ConsumerConnection> merged = new LinkedHashMap<>();
+ for (String group : groups) {
+ tasks.add(queryAllBrokers(brokers,
+ (addr, timeout) -> admin().getConsumerConnectionList(addr, group, timeout))
+ .handle((byBroker, throwable) -> {
+ // seed with the proxy-side view: gRPC v2 consumers never reach the broker, and a
+ // broker that does not know the group must not erase what this proxy does know
+ ConsumerConnection acc = proxySideConnection(group);
+ if (throwable != null) {
+ log.info("broker has no consumer connection for group {}: {}", group, throwable.getMessage());
+ } else {
+ for (ConsumerConnection connection : byBroker.values()) {
+ acc = mergeConsumerConnection(acc, connection);
+ }
+ }
+ if (acc != null) {
+ synchronized (merged) {
+ merged.put(group, acc);
+ }
+ }
+ return null;
+ }));
+ }
+ return CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0]))
+ .thenApply(ignored -> merged);
+ }
+
+ /**
+ * Consumers registered through this proxy cluster, synthesised into the same shape the broker
+ * reports so the two sources can be merged without the callers knowing where a client came from.
+ *
+ * <p>A gRPC v2 client keeps its channel on the proxy and is never registered in the broker's
+ * consumer manager, so a broker-only query returns nothing for it. {@code HeartbeatSyncer}
+ * replicates registrations between proxies, so this view also covers clients connected to a peer
+ * proxy (they appear as {@link org.apache.rocketmq.proxy.processor.channel.RemoteChannel}).
+ */
+ private ConsumerConnection proxySideConnection(String group) {
+ ConsumerManager consumerManager = serviceManager.getConsumerManager();
+ ConsumerGroupInfo groupInfo = consumerManager == null ? null : consumerManager.getConsumerGroupInfo(group);
+ if (groupInfo == null) {
+ return null;
+ }
+ ConsumerConnection connection = new ConsumerConnection();
+ connection.setConsumeType(groupInfo.getConsumeType());
+ connection.setMessageModel(groupInfo.getMessageModel());
+ connection.setConsumeFromWhere(groupInfo.getConsumeFromWhere());
+ if (groupInfo.getSubscriptionTable() != null) {
+ connection.getSubscriptionTable().putAll(groupInfo.getSubscriptionTable());
+ }
+ for (ClientChannelInfo channelInfo : groupInfo.getChannelInfoTable().values()) {
+ if (channelInfo == null) {
+ continue;
+ }
+ Connection client = new Connection();
+ client.setClientId(channelInfo.getClientId());
+ client.setLanguage(channelInfo.getLanguage());
+ client.setVersion(channelInfo.getVersion());
+ client.setClientAddr(remoteAddressOf(channelInfo));
+ connection.getConnectionSet().add(client);
+ }
+ return connection;
+ }
+
+ /** Netty renders a socket address as "/ip:port"; drop the leading slash so it parses as an IP. */
+ private static String remoteAddressOf(ClientChannelInfo channelInfo) {
+ if (channelInfo.getChannel() == null || channelInfo.getChannel().remoteAddress() == null) {
+ return "";
+ }
+ String address = channelInfo.getChannel().remoteAddress().toString();
+ return address.startsWith("/") ? address.substring(1) : address;
+ }
+
+ private static ConsumerConnection mergeConsumerConnection(ConsumerConnection target, ConsumerConnection source) {
+ if (source == null) {
+ return target;
+ }
+ if (target == null) {
+ ConsumerConnection copy = new ConsumerConnection();
+ copy.setConsumeType(source.getConsumeType());
+ copy.setMessageModel(source.getMessageModel());
+ copy.setConsumeFromWhere(source.getConsumeFromWhere());
+ copy.getConnectionSet().addAll(source.getConnectionSet());
+ copy.getSubscriptionTable().putAll(source.getSubscriptionTable());
+ return copy;
+ }
+ for (Connection connection : source.getConnectionSet()) {
+ boolean seen = false;
+ for (Connection existing : target.getConnectionSet()) {
+ if (existing.getClientId() != null && existing.getClientId().equals(connection.getClientId())) {
+ seen = true;
+ break;
+ }
+ }
+ if (!seen) {
+ target.getConnectionSet().add(connection);
+ }
+ }
+ target.getSubscriptionTable().putAll(source.getSubscriptionTable());
+ return target;
+ }
+
+ // =========================================================================
+ // 4. DescribeSubscription
+ // =========================================================================
+
+ @Override
+ public void describeSubscription(DescribeSubscriptionRequest request,
+ StreamObserver<DescribeSubscriptionResponse> responseObserver) {
+ boolean hasTopic = request.hasTopic() && !request.getTopic().getName().isEmpty();
+ boolean hasGroup = request.hasGroup() && !request.getGroup().getName().isEmpty();
+ if (!hasTopic && !hasGroup) {
+ respond(responseObserver, DescribeSubscriptionResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "at least one of topic or group must be set")).build());
+ return;
+ }
+ String topic = hasTopic ? request.getTopic().getName() : null;
+ String group = hasGroup ? request.getGroup().getName() : null;
+ resolveGroups(topic, group)
+ .thenCompose(groups -> allMasterBrokerAddrs()
+ .thenCompose(brokers -> collectConnections(brokers, groups)))
+ .whenComplete((connections, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, DescribeSubscriptionResponse.newBuilder().setStatus(err(throwable)).build());
+ return;
+ }
+ try {
+ respond(responseObserver, buildDescribeSubscriptionResponse(connections, topic));
+ } catch (Throwable t) {
+ respond(responseObserver, DescribeSubscriptionResponse.newBuilder().setStatus(err(t)).build());
+ }
+ });
+ }
+
+ /**
+ * One entry per connected client, which is what makes inconsistent subscriptions inside a group
+ * visible. Per-client settings are only held for clients connected to this proxy, so a client
+ * owned by a peer proxy is reported from the broker-side connection data with the group-level
+ * subscription rather than being dropped.
+ */
+ private DescribeSubscriptionResponse buildDescribeSubscriptionResponse(Map<String, ConsumerConnection> connections,
+ String topicFilter) {
+ DescribeSubscriptionResponse.Builder builder = DescribeSubscriptionResponse.newBuilder().setStatus(ok());
+ for (Map.Entry<String, ConsumerConnection> entry : connections.entrySet()) {
+ String group = entry.getKey();
+ ConsumerConnection connection = entry.getValue();
+ if (connection == null) {
+ continue;
+ }
+ apache.rocketmq.v2.MessageModel messageModel = AdminModelConverter.toMessageModel(connection.getMessageModel());
+ for (Connection conn : connection.getConnectionSet()) {
+ ClientInfo clientInfo = AdminModelConverter.toClientInfo(conn, messageModel);
+ apache.rocketmq.v2.Settings settings =
+ grpcClientSettingsManager.getRawClientSettings(conn.getClientId());
+ List<SubscriptionInfo> infos = settings != null && settings.hasSubscription()
+ ? subscriptionsFromSettings(group, settings, topicFilter, messageModel)
+ : subscriptionsFromConnection(group, connection, topicFilter);
+ for (SubscriptionInfo info : infos) {
+ builder.addClientSubscriptionInfo(
+ DescribeSubscriptionResponse.ClientSubscriptionInfo.newBuilder()
+ .setClientInfo(clientInfo)
+ .setSubscriptionInfo(info)
+ .build());
+ }
+ }
+ }
+ return builder.build();
+ }
+
+ private List<SubscriptionInfo> subscriptionsFromSettings(String group, apache.rocketmq.v2.Settings settings,
+ String topicFilter, apache.rocketmq.v2.MessageModel messageModel) {
+ List<SubscriptionInfo> result = new ArrayList<>();
+ for (apache.rocketmq.v2.SubscriptionEntry entry : settings.getSubscription().getSubscriptionsList()) {
+ String entryTopic = entry.hasTopic() ? entry.getTopic().getName() : "";
+ if (topicFilter != null && !topicFilter.equals(entryTopic)) {
+ continue;
+ }
+ SubscriptionInfo.Builder info = SubscriptionInfo.newBuilder()
+ .setGroup(resource(group))
+ .setTopic(resource(entryTopic))
+ .setOnline(true)
+ .setMessageModel(messageModel);
+ if (entry.hasExpression()) {
+ info.setExpression(entry.getExpression());
+ }
+ result.add(info.build());
+ }
+ return result;
+ }
+
+ private List<SubscriptionInfo> subscriptionsFromConnection(String group, ConsumerConnection connection,
+ String topicFilter) {
+ List<SubscriptionInfo> result = new ArrayList<>();
+ if (connection.getSubscriptionTable() == null) {
+ return result;
+ }
+ apache.rocketmq.v2.MessageModel messageModel = AdminModelConverter.toMessageModel(connection.getMessageModel());
+ for (Map.Entry<String, org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData> sub :
+ connection.getSubscriptionTable().entrySet()) {
+ if (topicFilter != null && !topicFilter.equals(sub.getKey())) {
+ continue;
+ }
+ org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData data = sub.getValue();
+ result.add(SubscriptionInfo.newBuilder()
+ .setGroup(resource(group))
+ .setTopic(resource(sub.getKey()))
+ .setExpression(AdminModelConverter.toFilterExpression(
+ data == null ? null : data.getExpressionType(), data == null ? null : data.getSubString()))
+ .setOnline(true)
+ .setMessageModel(messageModel)
+ .build());
+ }
+ return result;
+ }
+
+ // =========================================================================
+ // 5. DeleteSubscription
+ // =========================================================================
+
+ /**
+ * Deletes ONE subscription relationship (group + topic + filter expression), which is what the
+ * contract asks for. It deliberately does NOT delete the consumer group: an earlier version
+ * answered this request with {@code deleteSubscriptionGroup} on every broker hosting the topic,
+ * which destroyed the group's other subscriptions along with its offsets.
+ *
+ * <p>The broker has no request code for a granular delete, so this is a read-modify-write on
+ * {@link SubscriptionGroupConfig#getSubscriptionDataSet()} via UPDATE_AND_CREATE_SUBSCRIPTIONGROUP.
+ *
+ * <p><b>In open source this is effectively a no-op, and that is expected rather than a bug.</b>
+ * Nothing in the codebase ever writes {@code subscriptionDataSet}: the broker only reads it
+ * ({@code ConsumerLagCalculator}, to attribute lag to topics), so on a stock deployment the set
+ * is {@code null} and there is no persisted relationship to remove. Durable per-relationship
+ * subscriptions are a downstream concern: distributions that need this RPC back it with an
+ * external subscription store that populates the same field. Live subscriptions held in the
+ * broker's {@code ConsumerManager} are deliberately not touched: they are rebuilt from client
+ * heartbeats, so removing them there would be undone within one heartbeat interval and would
+ * only look like a successful delete.
+ *
+ * <p>Callers therefore observe {@link Code#NOT_FOUND} with an explanation, and the consumer group
+ * and its offsets are left untouched. Making this RPC functional requires a persisted
+ * subscription store (or a contract change), which is out of scope for the proxy.
+ */
+ @Override
+ public void deleteSubscription(DeleteSubscriptionRequest request,
+ StreamObserver<DeleteSubscriptionResponse> responseObserver) {
+ String topic = request.getTopic().getName();
+ String group = request.hasGroup() ? request.getGroup().getName() : "";
+ if (topic.isEmpty() || group.isEmpty()) {
+ respond(responseObserver, DeleteSubscriptionResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "topic and group are required")).build());
+ return;
+ }
+ boolean hasExpression = request.hasExpression() && !request.getExpression().getExpression().isEmpty();
+ String expression = hasExpression ? request.getExpression().getExpression() : null;
+ boolean sql = hasExpression && request.getExpression().getType() == apache.rocketmq.v2.FilterType.SQL;
+ List<String> brokerAddrs;
+ try {
+ brokerAddrs = topicBrokerAddrs(topic, true);
+ } catch (Throwable t) {
+ respond(responseObserver, DeleteSubscriptionResponse.newBuilder().setStatus(err(t)).build());
+ return;
+ }
+ log.info("deleteSubscription group={} topic={} expression={} brokers={}", group, topic, expression, brokerAddrs);
+
+ long timeout = timeoutMillis();
+ List<CompletableFuture<Boolean>> tasks = new ArrayList<>();
+ List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
+ for (String brokerAddr : brokerAddrs) {
+ tasks.add(admin().getSubscriptionGroupConfig(brokerAddr, group, timeout)
+ .thenCompose(config -> {
+ if (config == null) {
+ return CompletableFuture.completedFuture(false);
+ }
+ Set<SimpleSubscriptionData> dataSet = config.getSubscriptionDataSet();
+ if (dataSet == null || dataSet.isEmpty()) {
+ // the normal path on a stock deployment: nothing persists relationships here,
+ // so this falls through to the NOT_FOUND explained in the method javadoc
+ return CompletableFuture.completedFuture(false);
+ }
+ Set<SimpleSubscriptionData> remaining = new HashSet<>();
+ boolean removed = false;
+ for (SimpleSubscriptionData data : dataSet) {
+ if (matches(data, topic, expression, sql)) {
+ removed = true;
+ } else {
+ remaining.add(data);
+ }
+ }
+ if (!removed) {
+ return CompletableFuture.completedFuture(false);
+ }
+ config.setSubscriptionDataSet(remaining);
+ return admin().updateSubscriptionGroupConfig(brokerAddr, config, timeout).thenApply(v -> true);
+ })
+ .exceptionally(t -> {
+ failures.add(t);
+ log.warn("deleteSubscription failed on broker {} group={} topic={}", brokerAddr, group, topic, t);
+ return false;
+ }));
+ }
+ CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).whenComplete((ignored, throwable) -> {
+ boolean anyRemoved = false;
+ for (CompletableFuture<Boolean> task : tasks) {
+ if (Boolean.TRUE.equals(task.getNow(false))) {
+ anyRemoved = true;
+ }
+ }
+ if (anyRemoved) {
+ respond(responseObserver, DeleteSubscriptionResponse.newBuilder().setStatus(ok()).build());
+ } else if (!failures.isEmpty()) {
+ respond(responseObserver, DeleteSubscriptionResponse.newBuilder().setStatus(err(failures.get(0))).build());
+ } else {
+ // Nothing matched. Say why precisely instead of reporting success for a no-op, and
+ // make clear the consumer group itself was left alone: the previous implementation
+ // answered this request by deleting the whole group on every broker.
+ respond(responseObserver, DeleteSubscriptionResponse.newBuilder()
+ .setStatus(err(Code.NOT_FOUND,
+ "no subscription of group " + group + " on topic " + topic
+ + " is recorded on any broker. Open-source RocketMQ does not persist "
+ + "per-topic subscription relationships (SubscriptionGroupConfig."
+ + "subscriptionDataSet is only ever read by the broker, so it stays empty "
+ + "unless an external subscription store writes it), so there is nothing to "
+ + "delete. The consumer group and its offsets were left untouched."))
+ .build());
+ }
+ });
+ }
+
+ private static boolean matches(SimpleSubscriptionData data, String topic, String expression, boolean sql) {
+ if (data == null || !topic.equals(data.getTopic())) {
+ return false;
+ }
+ if (expression == null) {
+ return true;
+ }
+ if (!expression.equals(data.getExpression())) {
+ return false;
+ }
+ String type = sql ? org.apache.rocketmq.common.filter.ExpressionType.SQL92
+ : org.apache.rocketmq.common.filter.ExpressionType.TAG;
+ return data.getExpressionType() == null || type.equals(data.getExpressionType());
+ }
+
+ // =========================================================================
+ // 6. DescribeGroupAccumulation
+ // =========================================================================
+
+ @Override
+ public void describeGroupAccumulation(DescribeGroupAccumulationRequest request,
+ StreamObserver<DescribeGroupAccumulationResponse> responseObserver) {
+ String group = request.getGroup().getName();
+ if (group.isEmpty()) {
+ respond(responseObserver, DescribeGroupAccumulationResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "group is required")).build());
+ return;
+ }
+ Set<String> requested = new LinkedHashSet<>();
+ for (Resource resource : request.getTopicsList()) {
+ if (!resource.getName().isEmpty()) {
+ requested.add(resource.getName());
+ }
+ }
+ // The contract says an empty topic list means "the whole group". Resolving that by treating
+ // the group name as a topic name cannot work, so the topics the group actually consumes are
+ // asked from the brokers instead.
+ CompletableFuture<Set<String>> topics = requested.isEmpty()
+ ? resolveTopicsOfGroup(group)
+ : CompletableFuture.completedFuture(requested);
+
+ topics.thenCompose(topicSet -> allMasterBrokerAddrs()
+ .thenCompose(brokers -> queryAllBrokers(brokers,
+ (addr, timeout) -> admin().getConsumeStats(addr, group, "", timeout)))
+ .thenApply(byBroker -> AdminModelConverter.toAccumulation(
+ filterToTopics(byBroker, group, topicSet), group)))
+ .whenComplete((result, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, DescribeGroupAccumulationResponse.newBuilder()
+ .setStatus(err(throwable)).build());
+ return;
+ }
+ try {
+ DescribeGroupAccumulationResponse.Builder builder = DescribeGroupAccumulationResponse.newBuilder()
+ .setStatus(ok())
+ .setAccumulation(result.total);
+ for (Map.Entry<String, apache.rocketmq.v2.DescribeGroupAccumulationResponse.GroupAccumulation> e :
+ result.byTopic.entrySet()) {
+ builder.putTopicAccumulation(e.getKey(), e.getValue());
+ }
+ respond(responseObserver, builder.build());
+ } catch (Throwable t) {
+ respond(responseObserver, DescribeGroupAccumulationResponse.newBuilder().setStatus(err(t)).build());
+ }
+ });
+ }
+
+ /** Topics a group consumes, including its pop retry topics, asked from the brokers. */
+ private CompletableFuture<Set<String>> resolveTopicsOfGroup(String group) {
+ return allMasterBrokerAddrs().thenCompose(brokers ->
+ queryAllBrokers(brokers, (addr, timeout) -> admin().queryTopicsByConsumer(addr, group, timeout))
+ .thenApply(byBroker -> {
+ Set<String> topics = new LinkedHashSet<>();
+ for (TopicList topicList : byBroker.values()) {
+ if (topicList == null || topicList.getTopicList() == null) {
+ continue;
+ }
+ for (String topic : topicList.getTopicList()) {
+ if (topic.startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
+ continue;
+ }
+ topics.add(topic);
+ }
+ }
+ return topics;
+ }));
+ }
+
+ /**
+ * Keeps only the queues belonging to the requested topics. When the caller asked for the whole
+ * group everything is kept, so a caller scoping the query to one topic does not get the backlog
+ * of unrelated topics mixed in.
+ */
+ private Map<String, ConsumeStats> filterToTopics(Map<String, ConsumeStats> byBroker, String group,
+ Set<String> requestedTopics) {
+ if (requestedTopics.isEmpty()) {
+ return byBroker;
+ }
+ Set<String> wanted = new HashSet<>();
+ for (String topic : requestedTopics) {
+ wanted.add(topic);
+ // a topic's backlog is spread over its own queues and the group's retry queues for it
+ wanted.add(KeyBuilder.buildPopRetryTopic(topic, group));
+ wanted.add(KeyBuilder.buildPopRetryTopicV2(topic, group));
+ }
+ wanted.add(MixAll.getRetryTopic(group));
+ Map<String, ConsumeStats> filtered = new LinkedHashMap<>();
+ for (Map.Entry<String, ConsumeStats> entry : byBroker.entrySet()) {
+ ConsumeStats stats = entry.getValue();
+ if (stats == null || stats.getOffsetTable() == null) {
+ continue;
+ }
+ ConsumeStats copy = new ConsumeStats();
+ for (Map.Entry<MessageQueue, org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper> e :
+ stats.getOffsetTable().entrySet()) {
+ if (e.getKey() != null && wanted.contains(e.getKey().getTopic())) {
+ copy.getOffsetTable().put(e.getKey(), e.getValue());
+ }
+ }
+ filtered.put(entry.getKey(), copy);
+ }
+ return filtered;
+ }
+
+ // =========================================================================
+ // 7. ListConsumerConnection
+ // =========================================================================
+
+ @Override
+ public void listConsumerConnection(ListConsumerConnectionRequest request,
+ StreamObserver<ListConsumerConnectionResponse> responseObserver) {
+ String group = request.getGroup().getName();
+ if (group.isEmpty()) {
+ respond(responseObserver, ListConsumerConnectionResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "group is required")).build());
+ return;
+ }
+ String topicFilter = request.hasTopic() && !request.getTopic().getName().isEmpty()
+ ? request.getTopic().getName() : null;
+ allMasterBrokerAddrs()
+ .thenCompose(brokers -> collectConnections(brokers, Collections.singletonList(group)))
+ .whenComplete((byGroup, throwable) -> {
+ if (throwable != null) {
+ // an offline group is a normal answer for a listing RPC, not a server error
+ log.info("listConsumerConnection group={} unavailable: {}", group, throwable.getMessage());
+ respond(responseObserver, ListConsumerConnectionResponse.newBuilder()
+ .setStatus(ok()).build());
+ return;
+ }
+ try {
+ ConsumerConnection merged = byGroup.get(group);
+ ListConsumerConnectionResponse.Builder builder =
+ ListConsumerConnectionResponse.newBuilder().setStatus(ok());
+ if (merged != null) {
+ if (topicFilter != null && (merged.getSubscriptionTable() == null
+ || !merged.getSubscriptionTable().containsKey(topicFilter))) {
+ respond(responseObserver, builder.build());
+ return;
+ }
+ apache.rocketmq.v2.MessageModel messageModel =
+ AdminModelConverter.toMessageModel(merged.getMessageModel());
+ for (Connection connection : merged.getConnectionSet()) {
+ builder.addClientInfo(AdminModelConverter.toClientInfo(connection, messageModel));
+ }
+ }
+ respond(responseObserver, builder.build());
+ } catch (Throwable t) {
+ respond(responseObserver, ListConsumerConnectionResponse.newBuilder()
+ .setStatus(err(t)).build());
+ }
+ });
+ }
+
+ // =========================================================================
+ // 8. ResetGroupOffset
+ // =========================================================================
+
+ @Override
+ public void resetGroupOffset(ResetGroupOffsetRequest request, StreamObserver<ResetGroupOffsetResponse> responseObserver) {
+ String group = request.getGroup().getName();
+ String topic = request.getTopic().getName();
+ if (group.isEmpty() || topic.isEmpty()) {
+ respond(responseObserver, ResetGroupOffsetResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "group and topic are required")).build());
+ return;
+ }
+ // An unset protobuf Timestamp is 0, which would silently mean "replay everything since 1970".
+ if (!request.hasResetTimestamp() || request.getResetTimestamp().getSeconds() <= 0) {
+ respond(responseObserver, ResetGroupOffsetResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "reset_timestamp is required and must be a positive Unix time"))
+ .build());
+ return;
+ }
+ long resetTimestamp = TimeUnit.SECONDS.toMillis(request.getResetTimestamp().getSeconds())
+ + TimeUnit.NANOSECONDS.toMillis(request.getResetTimestamp().getNanos());
+ List<String> brokerAddrs;
+ try {
+ brokerAddrs = topicBrokerAddrs(topic, true);
+ } catch (Throwable t) {
+ respond(responseObserver, ResetGroupOffsetResponse.newBuilder().setStatus(err(t)).build());
+ return;
+ }
+ log.info("resetGroupOffset group={} topic={} timestamp={} brokers={}", group, topic, resetTimestamp, brokerAddrs);
+ queryAllBrokers(brokerAddrs, (addr, timeout) ->
+ admin().resetOffset(addr, topic, group, resetTimestamp, true, timeout))
+ .whenComplete((ignored, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, ResetGroupOffsetResponse.newBuilder().setStatus(err(throwable)).build());
+ } else {
+ respond(responseObserver, ResetGroupOffsetResponse.newBuilder().setStatus(ok()).build());
+ }
+ });
+ }
+
+ // =========================================================================
+ // 9. QueryMessage
+ // =========================================================================
+
+ @Override
+ public void queryMessage(ListMessageRequest request, StreamObserver<ListMessageResponse> responseObserver) {
+ String topic = request.getTopic().getName();
+ if (topic.isEmpty()) {
+ respond(responseObserver, ListMessageResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "topic is required")).build());
+ return;
+ }
+ int maxNums = request.getMaxMessageNums() > 0 ? request.getMaxMessageNums() : DEFAULT_MAX_MESSAGE_NUMS;
+ long begin = request.hasBeginTimestamp() ? TimeUnit.SECONDS.toMillis(request.getBeginTimestamp().getSeconds()) : 0L;
+ long end = request.hasEndTimestamp() ? TimeUnit.SECONDS.toMillis(request.getEndTimestamp().getSeconds())
+ : Long.MAX_VALUE;
+
+ List<String> brokerAddrs;
+ try {
+ brokerAddrs = topicBrokerAddrs(topic, false);
+ } catch (Throwable t) {
+ respond(responseObserver, ListMessageResponse.newBuilder().setStatus(err(t)).build());
+ return;
+ }
+
+ String key;
+ boolean uniqueKey;
+ switch (request.getSearchKeyCase()) {
+ case MESSAGE_ID:
+ // a v2 message_id is the client-generated unique key, not the broker's offset-encoded
+ // id, so it must be looked up through the unique-key index on every broker that
+ // hosts the topic — decoding it as an offset would query unrelated data
+ key = request.getMessageId();
+ uniqueKey = true;
+ break;
+ case MESSAGE_KEY:
+ key = request.getMessageKey();
+ uniqueKey = false;
+ break;
+ case SUBSCRIPTION:
+ case LITE_TOPIC:
+ case SEARCHKEY_NOT_SET:
+ default:
+ respond(responseObserver, ListMessageResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST,
+ "querying by " + request.getSearchKeyCase() + " is not supported; "
+ + "message_id or message_key is required"))
+ .build());
+ return;
+ }
+ if (key == null || key.isEmpty()) {
+ respond(responseObserver, ListMessageResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "the search key must not be empty")).build());
+ return;
+ }
+
+ final String queryKey = key;
+ final boolean queryUniqueKey = uniqueKey;
+ queryAllBrokers(brokerAddrs, (addr, timeout) ->
+ admin().queryMessage(addr, topic, queryKey, maxNums, begin, end, queryUniqueKey, true, timeout))
+ .whenComplete((byBroker, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, ListMessageResponse.newBuilder().setStatus(err(throwable)).build());
+ return;
+ }
+ ListMessageResponse.Builder builder = ListMessageResponse.newBuilder().setStatus(ok());
+ if (request.hasScrollId()) {
+ builder.setScrollId(request.getScrollId());
+ }
+ int count = 0;
+ outer:
+ for (List<MessageExt> messages : byBroker.values()) {
+ if (messages == null) {
+ continue;
+ }
+ for (MessageExt messageExt : messages) {
+ if (messageExt == null) {
+ continue;
+ }
+ if (count >= maxNums) {
+ break outer;
+ }
+ // reuse the data plane converter so born/store timestamps, hosts, delivery
+ // time, message group and user properties are all carried over
+ builder.addMessages(GrpcConverter.getInstance().buildMessage(messageExt));
+ count++;
+ }
+ }
+ if (count == 0) {
+ builder.setStatus(err(Code.MESSAGE_NOT_FOUND,
+ "no message found for " + (queryUniqueKey ? "message_id " : "message_key ") + queryKey));
+ }
+ respond(responseObserver, builder.build());
+ });
+ }
+
+ // =========================================================================
+ // 10. PrintThreadStackTrace
+ // =========================================================================
+
+ @Override
+ public void printThreadStackTrace(PrintThreadStackTraceRequest request,
+ StreamObserver<PrintThreadStackTraceResponse> responseObserver) {
+ String group = request.getGroup().getName();
+ String clientId = request.getClientId();
+ if (forwarder.forwardIfRemote(group, clientId, responseObserver,
+ (stub, observer) -> stub.printThreadStackTrace(request, observer))) {
+ return;
+ }
+ relayConsumerRunningInfo(group, clientId, true)
+ .whenComplete((result, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, PrintThreadStackTraceResponse.newBuilder()
+ .setStatus(err(throwable)).build());
+ return;
+ }
+ org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo runningInfo = result.getResult();
+ String jstack = runningInfo == null ? null : runningInfo.getJstack();
+ if (result.getCode() != ResponseCode.SUCCESS || jstack == null || jstack.isEmpty()) {
+ respond(responseObserver, PrintThreadStackTraceResponse.newBuilder()
+ .setStatus(err(Code.NOT_FOUND,
+ "client " + clientId + " did not return a thread stack: " + result.getRemark()))
+ .build());
+ return;
+ }
+ respond(responseObserver, PrintThreadStackTraceResponse.newBuilder()
+ .setStatus(ok())
+ .setThreadStackTrace(jstack)
+ .build());
+ });
+ }
+
+ // =========================================================================
+ // 11. VerifyMessage
+ // =========================================================================
+
+ @Override
+ public void verifyMessage(VerifyMessageRequest request, StreamObserver<VerifyMessageResponse> responseObserver) {
+ String group = request.getGroup().getName();
+ String clientId = request.getClientId();
+ String topic = request.getTopic().getName();
+ String messageId = request.getMessageId();
+ if (group.isEmpty() || clientId.isEmpty() || topic.isEmpty() || messageId.isEmpty()) {
+ respond(responseObserver, VerifyMessageResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "group, client_id, topic and message_id are required")).build());
+ return;
+ }
+ if (forwarder.forwardIfRemote(group, clientId, responseObserver,
+ (stub, observer) -> stub.verifyMessage(request, observer))) {
+ return;
+ }
+ // the real message has to be fetched first: asking a client to consume an empty shell would
+ // prove nothing about its consumer logic
+ List<String> brokerAddrs;
+ try {
+ brokerAddrs = topicBrokerAddrs(topic, false);
+ } catch (Throwable t) {
+ respond(responseObserver, VerifyMessageResponse.newBuilder().setStatus(err(t)).build());
+ return;
+ }
+ // The message is fetched with its body still compressed: it is handed to the client
+ // verbatim, so inflating it here would leave the body and the compression flag the client
+ // reads disagreeing with each other.
+ queryAllBrokers(brokerAddrs, (addr, timeout) ->
+ admin().queryMessage(addr, topic, messageId, 1, 0L, Long.MAX_VALUE, true, false, timeout))
+ .whenComplete((byBroker, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, VerifyMessageResponse.newBuilder().setStatus(err(throwable)).build());
+ return;
+ }
+ MessageExt messageExt = null;
+ for (List<MessageExt> messages : byBroker.values()) {
+ if (messages != null && !messages.isEmpty() && messages.get(0) != null) {
+ messageExt = messages.get(0);
+ break;
+ }
+ }
+ if (messageExt == null) {
+ // "no such message" is a normal answer, not a proxy fault
+ respond(responseObserver, VerifyMessageResponse.newBuilder()
+ .setStatus(err(Code.MESSAGE_NOT_FOUND,
+ "message " + messageId + " not found on topic " + topic))
+ .build());
+ return;
+ }
+ relayConsumeMessageDirectly(group, clientId, messageExt)
+ .whenComplete((result, relayThrowable) -> {
+ if (relayThrowable != null) {
+ respond(responseObserver, VerifyMessageResponse.newBuilder()
+ .setStatus(err(relayThrowable)).build());
+ return;
+ }
+ ConsumeMessageDirectlyResult directlyResult = result.getResult();
+ CMResult consumeResult = directlyResult == null ? null : directlyResult.getConsumeResult();
+ if (consumeResult == CMResult.CR_SUCCESS) {
+ respond(responseObserver, VerifyMessageResponse.newBuilder().setStatus(ok()).build());
+ } else {
+ respond(responseObserver, VerifyMessageResponse.newBuilder()
+ .setStatus(err(Code.MESSAGE_CORRUPTED,
+ "client " + clientId + " failed to consume message " + messageId + ": "
+ + (consumeResult == null ? result.getRemark() : consumeResult.name())
+ + (directlyResult != null && directlyResult.getRemark() != null
+ ? " (" + directlyResult.getRemark() + ")" : "")))
+ .build());
+ }
+ });
+ });
+ }
+
+ // =========================================================================
+ // 12. AdminSendMessage
+ // =========================================================================
+
+ @Override
+ public void adminSendMessage(AdminSendMessageRequest request, StreamObserver<AdminSendMessageResponse> responseObserver) {
+ String topic = request.getTopic().getName();
+ if (topic.isEmpty()) {
+ respond(responseObserver, AdminSendMessageResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "topic is required")).build());
+ return;
+ }
+ org.apache.rocketmq.common.message.Message message;
+ try {
+ message = buildAdminMessage(request);
+ } catch (Throwable t) {
+ // building the message validates caller-supplied properties, so a rejection has to be
+ // answered with a Status; letting it escape this method would reply with a bare gRPC
+ // error carrying no Status at all
+ respond(responseObserver, AdminSendMessageResponse.newBuilder().setStatus(err(t)).build());
+ return;
+ }
+ // ext_info is request-scoped and unknown keys must be ignored, so it is only logged here
+ if (!request.getExtInfoMap().isEmpty()) {
+ log.info("adminSendMessage ext_info={}", request.getExtInfoMap());
+ }
+
+ List<org.apache.rocketmq.common.message.Message> messages = new ArrayList<>();
+ messages.add(message);
+ final String shardingKey = message.getProperty(MessageConst.PROPERTY_SHARDING_KEY);
+ long timeout = timeoutMillis();
+ CompletableFuture<List<SendResult>> sendFuture;
+ try {
+ sendFuture = messagingProcessor.sendMessage(ctx(),
+ (queueContext, messageQueueView) -> selectQueue(messageQueueView, shardingKey),
+ ADMIN_SEND_PRODUCER_GROUP, 0, messages, timeout);
+ } catch (Throwable t) {
+ respond(responseObserver, AdminSendMessageResponse.newBuilder().setStatus(err(t)).build());
+ return;
+ }
+ withTimeout(sendFuture, timeout * 2, "adminSendMessage")
+ .whenComplete((sendResults, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, AdminSendMessageResponse.newBuilder().setStatus(err(throwable)).build());
+ return;
+ }
+ String messageId = sendResults != null && !sendResults.isEmpty() && sendResults.get(0) != null
+ ? sendResults.get(0).getMsgId() : "";
+ respond(responseObserver, AdminSendMessageResponse.newBuilder()
+ .setStatus(ok())
+ .setMessageId(messageId == null ? "" : messageId)
+ .build());
+ });
+ }
+
+ /**
+ * Builds the message an admin send request describes, mirroring what the data plane's
+ * {@code SendMessageActivity} does for a producer.
+ *
+ * <p>System properties are written with {@link MessageAccessor#putProperty}, never with
+ * {@code Message#putUserProperty}: the latter rejects every name in
+ * {@link MessageConst#STRING_HASH_SET}, so using it for a system property such as the delivery
+ * timestamp throws instead of sending the message. Caller-supplied user properties are checked
+ * against that same set first and refused as {@link Code#ILLEGAL_MESSAGE_PROPERTY_KEY}, so a
+ * request that tries to forge a system property gets a graded answer rather than an internal
+ * error.
+ */
+ private static org.apache.rocketmq.common.message.Message buildAdminMessage(AdminSendMessageRequest request) {
+ org.apache.rocketmq.common.message.Message message = new org.apache.rocketmq.common.message.Message(
+ request.getTopic().getName(), request.getBody().toByteArray());
+ if (request.hasTag() && !request.getTag().isEmpty()) {
+ message.setTags(request.getTag());
+ }
+ if (request.hasKey() && !request.getKey().isEmpty()) {
+ message.setKeys(request.getKey());
+ }
+ for (Map.Entry<String, String> property : request.getUserPropertiesMap().entrySet()) {
+ if (MessageConst.STRING_HASH_SET.contains(property.getKey())) {
+ throw new GrpcProxyException(Code.ILLEGAL_MESSAGE_PROPERTY_KEY,
+ "property is used by system: " + property.getKey());
+ }
+ MessageAccessor.putProperty(message, property.getKey(), property.getValue());
+ }
+ if (!request.hasSystemProperties()) {
+ return message;
+ }
+ SystemProperties systemProperties = request.getSystemProperties();
+ if (systemProperties.hasDeliveryTimestamp()) {
+ Timestamp deliveryTimestamp = systemProperties.getDeliveryTimestamp();
+ if (!Timestamps.isValid(deliveryTimestamp)) {
+ throw new GrpcProxyException(Code.ILLEGAL_DELIVERY_TIME,
+ "delivery_timestamp is not a valid timestamp");
+ }
+ long deliverAt = Timestamps.toMillis(deliveryTimestamp);
+ if (deliverAt <= System.currentTimeMillis()) {
+ throw new GrpcProxyException(Code.ILLEGAL_DELIVERY_TIME,
+ "delivery_timestamp must be in the future");
+ }
+ MessageAccessor.putProperty(message, MessageConst.PROPERTY_TIMER_DELIVER_MS, String.valueOf(deliverAt));
+ }
+ if (!systemProperties.getMessageGroup().isEmpty()) {
+ MessageAccessor.putProperty(message, MessageConst.PROPERTY_SHARDING_KEY,
+ systemProperties.getMessageGroup());
+ }
+ for (String key : systemProperties.getKeysList()) {
+ if (!key.isEmpty()) {
+ message.setKeys(key);
+ break;
+ }
+ }
+ if (systemProperties.hasTag() && !systemProperties.getTag().isEmpty()) {
+ message.setTags(systemProperties.getTag());
+ }
+ return message;
+ }
+
+ /**
+ * A FIFO message must keep going to the queue its message group hashes to; anything else is
+ * spread over the writable queues instead of always landing on queue 0.
+ */
+ private static AddressableMessageQueue selectQueue(MessageQueueView messageQueueView, String shardingKey) {
+ List<AddressableMessageQueue> queues = messageQueueView.getWriteSelector().getQueues();
+ if (queues == null || queues.isEmpty()) {
+ throw new IllegalStateException("no writable queue available");
+ }
+ if (shardingKey != null && !shardingKey.isEmpty()) {
+ int index = Math.floorMod(shardingKey.hashCode(), queues.size());
+ return queues.get(index);
+ }
+ return queues.get(ThreadLocalRandom.current().nextInt(queues.size()));
+ }
+
+ // =========================================================================
+ // 13. GetConsumerRunningInfo
+ // =========================================================================
+
+ @Override
+ public void getConsumerRunningInfo(GetConsumerRunningInfoRequest request,
+ StreamObserver<GetConsumerRunningInfoResponse> responseObserver) {
+ String group = request.getGroup().getName();
+ String clientId = request.getClientId();
+ if (clientId.isEmpty()) {
+ respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "client_id is required")).build());
+ return;
+ }
+ if (forwarder.forwardIfRemote(group, clientId, responseObserver,
+ (stub, observer) -> stub.getConsumerRunningInfo(request, observer))) {
+ return;
+ }
+ relayConsumerRunningInfo(group, clientId, false)
+ .whenComplete((result, throwable) -> {
+ if (throwable == null && result.getCode() == ResponseCode.SUCCESS && result.getResult() != null) {
+ respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+ .setStatus(ok())
+ .setConsumerRunningInfo(AdminModelConverter.toConsumerRunningInfo(result.getResult()))
+ .build());
+ return;
+ }
+ // A gRPC v2 client cannot report running info: the telemetry contract has no reply
+ // message carrying properties, process queue snapshots or consume statistics. Only
+ // remoting clients can. Rather than returning an empty shell that looks like a
+ // healthy consumer, fall back to what the proxy does know and say so.
+ ConsumerRunningInfo.Builder builder = ConsumerRunningInfo.newBuilder();
+ apache.rocketmq.v2.Settings settings = grpcClientSettingsManager.getRawClientSettings(clientId);
+ if (settings == null || !settings.hasSubscription()) {
+ respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+ .setStatus(err(throwable != null ? throwable
+ : new IllegalStateException("client " + clientId + " is not connected to this cluster")))
+ .build());
+ return;
+ }
+ for (apache.rocketmq.v2.SubscriptionEntry entry : settings.getSubscription().getSubscriptionsList()) {
+ builder.putSubscriptions(entry.hasTopic() ? entry.getTopic().getName() : "",
+ entry.hasExpression() ? entry.getExpression()
+ : apache.rocketmq.v2.FilterExpression.newBuilder()
+ .setType(apache.rocketmq.v2.FilterType.TAG).setExpression("*").build());
+ }
+ // The client was found and its subscriptions are returned, so this is a success with
+ // an explanatory message rather than NOT_FOUND, which would claim the client does not
+ // exist and hide the fact that usable data came back.
+ respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+ .setStatus(ResponseBuilder.getInstance().buildStatus(Code.OK,
+ "only subscriptions are available: a gRPC v2 client does not report properties, "
+ + "process queue snapshots or consume statistics over the telemetry channel; "
+ + "use a remoting client for the full running info"))
+ .setConsumerRunningInfo(builder.build())
+ .build());
+ });
+ }
+
+ // =========================================================================
+ // 14. GetTopicRoute
+ // =========================================================================
+
+ @Override
+ public void getTopicRoute(GetTopicRouteRequest request, StreamObserver<GetTopicRouteResponse> responseObserver) {
+ String topic = request.getTopic().getName();
+ if (topic.isEmpty()) {
+ respond(responseObserver, GetTopicRouteResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "topic is required")).build());
+ return;
+ }
+ admin().getTopicRouteData(topic).whenComplete((topicRouteData, throwable) -> {
+ if (throwable != null) {
+ respond(responseObserver, GetTopicRouteResponse.newBuilder().setStatus(err(throwable)).build());
+ return;
+ }
+ // topic_route_data is documented as the serialized route table, so the request's
+ // network_type / protocol_type / client_address hints have no bearing on it: they only
+ // matter when the response carries protocol-specific access points, which this one does not
+ respond(responseObserver, AdminModelConverter.toTopicRoute((TopicRouteData) topicRouteData));
+ });
+ }
+
+ // =========================================================================
+ // 15. QueryTimeSpan
+ // =========================================================================
+
+ @Override
+ public void queryTimeSpan(QueryTimeSpanRequest request, StreamObserver<QueryTimeSpanResponse> responseObserver) {
+ String group = request.getGroup().getName();
+ if (group.isEmpty()) {
+ respond(responseObserver, QueryTimeSpanResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "group is required")).build());
+ return;
+ }
+ Set<String> topics = new LinkedHashSet<>();
+ for (Resource resource : request.getTopicsList()) {
+ if (!resource.getName().isEmpty()) {
+ topics.add(resource.getName());
+ }
+ }
+ if (topics.isEmpty()) {
+ respond(responseObserver, QueryTimeSpanResponse.newBuilder()
+ .setStatus(err(Code.BAD_REQUEST, "at least one topic is required")).build());
+ return;
+ }
+ // every requested topic is queried, on every broker hosting it; the broker computes the
+ // per-queue min/max/consume timestamps and delay itself (QUERY_CONSUME_TIME_SPAN)
+ List<CompletableFuture<List<QueueTimeSpan>>> tasks = new ArrayList<>();
+ List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
+ for (String topic : topics) {
+ List<String> brokerAddrs;
+ try {
+ brokerAddrs = topicBrokerAddrs(topic, false);
+ } catch (Throwable t) {
+ failures.add(t);
+ log.warn("queryTimeSpan cannot resolve brokers for topic {} group {}", topic, group, t);
+ continue;
+ }
+ tasks.add(queryAllBrokers(brokerAddrs, (addr, timeout) ->
+ admin().queryConsumeTimeSpan(addr, topic, group, timeout))
+ .thenApply(byBroker -> {
+ List<QueueTimeSpan> spans = new ArrayList<>();
+ for (List<QueueTimeSpan> perBroker : byBroker.values()) {
+ if (perBroker != null) {
+ spans.addAll(perBroker);
+ }
+ }
+ return spans;
+ })
+ .exceptionally(t -> {
+ failures.add(t);
+ return Collections.emptyList();
+ }));
+ }
+ CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).whenComplete((ignored, throwable) -> {
+ List<QueueTimeSpan> all = new ArrayList<>();
+ for (CompletableFuture<List<QueueTimeSpan>> task : tasks) {
+ List<QueueTimeSpan> spans = task.getNow(null);
+ if (spans != null) {
+ all.addAll(spans);
+ }
+ }
+ if (all.isEmpty() && !failures.isEmpty()) {
+ respond(responseObserver, QueryTimeSpanResponse.newBuilder().setStatus(err(failures.get(0))).build());
+ return;
+ }
+ try {
+ respond(responseObserver, AdminModelConverter.toQueryTimeSpan(all));
+ } catch (Throwable t) {
+ respond(responseObserver, QueryTimeSpanResponse.newBuilder().setStatus(err(t)).build());
+ }
+ });
+ }
+
+ // =========================================================================
+ // 16. GetProxyRuntimeStats
+ // =========================================================================
+
+ @Override
+ public void getProxyRuntimeStats(GetProxyRuntimeStatsRequest request,
+ StreamObserver<GetProxyRuntimeStatsResponse> responseObserver) {
+ try {
+ long producers = 0L;
+ long consumers = 0L;
+ Collection<GrpcClientChannel> channels = grpcChannelManager.getClientChannels();
+ for (GrpcClientChannel channel : channels) {
+ apache.rocketmq.v2.Settings settings = grpcClientSettingsManager.getRawClientSettings(channel.getClientId());
+ if (settings == null) {
+ continue;
+ }
+ switch (settings.getClientType()) {
+ case PRODUCER:
+ producers++;
+ break;
+ case PUSH_CONSUMER:
+ case SIMPLE_CONSUMER:
+ case PULL_CONSUMER:
+ case LITE_PUSH_CONSUMER:
+ case LITE_SIMPLE_CONSUMER:
+ consumers++;
+ break;
+ default:
+ break;
+ }
+ }
+ // in_tps / out_tps stay unset on purpose: the open-source proxy keeps no per-process
+ // throughput counter, and reporting 0 would be indistinguishable from an idle proxy
+ respond(responseObserver, GetProxyRuntimeStatsResponse.newBuilder()
+ .setStatus(ok())
+ .setProxyName(ConfigurationManager.getProxyConfig().getProxyName())
+ .setVersion(MQVersion.getVersionDesc(MQVersion.CURRENT_VERSION))
+ .setConnections(channels.size())
+ .setProducers(producers)
+ .setConsumers(consumers)
+ .build());
+ } catch (Throwable t) {
+ log.warn("getProxyRuntimeStats failed", t);
+ respond(responseObserver, GetProxyRuntimeStatsResponse.newBuilder().setStatus(err(t)).build());
+ }
+ }
+
+ // =========================================================================
+ // client relay
+ // =========================================================================
+
+ /**
+ * Asks a connected client for its running info through the proxy's in-process relay. The result
+ * future is carried on the {@link ProxyRelayRequest} so that
+ * {@code ProxyChannel.writeAndFlush} can hand it to the channel implementation, which completes
+ * it when the client answers.
+ */
+ private CompletableFuture<ProxyRelayResult<org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo>>
+ relayConsumerRunningInfo(String group, String clientId, boolean jstack) {
+ CompletableFuture<ProxyRelayResult<org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo>> future =
+ new CompletableFuture<>();
+ ClientChannelInfo channelInfo = findClientChannel(group, clientId);
+ if (channelInfo == null || channelInfo.getChannel() == null) {
+ future.complete(new ProxyRelayResult<>(ResponseCode.SYSTEM_ERROR,
+ "client " + clientId + " is not connected to this cluster", null));
+ return future;
+ }
+ if (!channelInfo.getChannel().isActive()) {
+ future.complete(new ProxyRelayResult<>(ResponseCode.SYSTEM_ERROR,
+ "client " + clientId + " channel is inactive", null));
+ return future;
+ }
+ try {
+ GetConsumerRunningInfoRequestHeader header = new GetConsumerRunningInfoRequestHeader();
+ header.setConsumerGroup(group);
+ header.setClientId(clientId);
+ header.setJstackEnable(jstack);
+ ProxyRelayRequest relayRequest = ProxyRelayRequest.createRequestCommand(
+ RequestCode.GET_CONSUMER_RUNNING_INFO, header, future);
+ channelInfo.getChannel().writeAndFlush(relayRequest);
+ } catch (Throwable t) {
+ future.completeExceptionally(t);
+ return future;
+ }
+ return withTimeout(future, relayTimeoutMillis(), "getConsumerRunningInfo for client " + clientId);
+ }
+
+ private CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> relayConsumeMessageDirectly(
+ String group, String clientId, MessageExt messageExt) {
+ CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> future = new CompletableFuture<>();
+ ClientChannelInfo channelInfo = findClientChannel(group, clientId);
+ if (channelInfo == null || channelInfo.getChannel() == null) {
+ future.complete(new ProxyRelayResult<>(ResponseCode.SYSTEM_ERROR,
+ "client " + clientId + " is not connected to this cluster", null));
+ return future;
+ }
+ try {
+ ConsumeMessageDirectlyResultRequestHeader header = new ConsumeMessageDirectlyResultRequestHeader();
+ header.setConsumerGroup(group);
+ header.setClientId(clientId);
+ header.setTopic(messageExt.getTopic());
+ header.setMsgId(messageExt.getMsgId());
+ // the header requires a broker name although the write never reaches a broker; the
+ // message's own store queue is the most meaningful value available here
+ header.setBrokerName(messageExt.getBrokerName() == null ? "" : messageExt.getBrokerName());
+ ProxyRelayRequest relayRequest = ProxyRelayRequest.createRequestCommand(
+ RequestCode.CONSUME_MESSAGE_DIRECTLY, header, future);
+ // storeSize is what the broker recorded on disk and MessageDecoder.encode allocates
+ // exactly that many bytes; zero it so the size is recomputed from what is actually being
+ // written, or a body that no longer matches the stored one overflows the buffer
+ messageExt.setStoreSize(0);
+ relayRequest.setBody(org.apache.rocketmq.common.message.MessageDecoder.encode(messageExt, false));
+ channelInfo.getChannel().writeAndFlush(relayRequest);
+ } catch (Throwable t) {
+ future.completeExceptionally(t);
+ return future;
+ }
+ return withTimeout(future, relayTimeoutMillis(), "verifyMessage for client " + clientId);
+ }
+
+ private long relayTimeoutMillis() {
+ return TimeUnit.SECONDS.toMillis(
+ ConfigurationManager.getProxyConfig().getGrpcProxyRelayRequestTimeoutInSeconds()) + 1000L;
+ }
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void shutdown() {
+ this.adminTimeoutScheduler.shutdownNow();
+ }
+
+ @Override
+ public String toString() {
+ return "ProxyAdminGrpcService{proxyName=" + ConfigurationManager.getProxyConfig().getProxyName()
+ + ", adminPort=" + ConfigurationManager.getProxyConfig().getGrpcAdminServerPort() + "}";
+ }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java
index 88099207b9..fc602c99ea 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java
@@ -97,6 +97,14 @@ public class DefaultGrpcMessagingActivity extends AbstractStartAndShutdown imple
this.appendStartAndShutdown(this.grpcClientSettingsManager);
}
+ public GrpcChannelManager getGrpcChannelManager() {
+ return this.grpcChannelManager;
+ }
+
+ public GrpcClientSettingsManager getGrpcClientSettingsManager() {
+ return this.grpcClientSettingsManager;
+ }
+
@Override
public CompletableFuture<QueryRouteResponse> queryRoute(ProxyContext ctx, QueryRouteRequest request) {
return this.routeActivity.queryRoute(ctx, request);
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java
index 3429ad54e2..a2b1150bf2 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java
@@ -78,6 +78,10 @@ public class GrpcMessagingApplication extends MessagingServiceGrpc.MessagingServ
private final GrpcMessagingActivity grpcMessagingActivity;
+ public GrpcMessagingActivity getGrpcMessagingActivity() {
+ return this.grpcMessagingActivity;
+ }
+
protected final RequestPipeline requestPipeline;
protected ThreadPoolExecutor routeThreadPoolExecutor;
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java
index a18cf7600c..306b5ee486 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.proxy.grpc.v2.channel;
+import java.util.Collection;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
@@ -69,6 +70,10 @@ public class GrpcChannelManager implements StartAndShutdown {
return clientIdChannelMap.get(clientId);
}
+ public Collection<GrpcClientChannel> getClientChannels() {
+ return clientIdChannelMap.values();
+ }
+
public GrpcClientChannel removeChannel(String clientId) {
return this.clientIdChannelMap.remove(clientId);
}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java
index 0135818fb3..7c42b7f47d 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java
@@ -52,6 +52,7 @@ import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
import org.apache.rocketmq.proxy.service.relay.ProxyRelayService;
import org.apache.rocketmq.proxy.service.transaction.TransactionData;
import org.apache.rocketmq.remoting.protocol.RemotingCommand;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
import org.apache.rocketmq.remoting.protocol.header.CheckTransactionStateRequestHeader;
@@ -233,6 +234,21 @@ public class GrpcClientChannel extends ProxyChannel implements ChannelExtendAttr
GetConsumerRunningInfoRequestHeader header,
CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> responseFuture) {
if (Objects.isNull(header) || !header.isJstackEnable()) {
+ // The gRPC v2 telemetry protocol only offers PrintThreadStackTraceCommand for this
+ // request: there is no command that reports consumer running info without a stack dump.
+ // Returning without touching responseFuture used to leave the caller hanging until
+ // GrpcChannelManager#scanExpireResultFuture fired SYSTEM_BUSY "call remote timeout",
+ // which both wastes grpcProxyRelayRequestTimeoutInSeconds and misreports the cause as a
+ // timeout. REQUEST_CODE_NOT_SUPPORTED is the accurate code here: the request itself is
+ // understood and the peer is healthy, the protocol simply cannot serve this variant.
+ // (SYSTEM_ERROR/SYSTEM_BUSY would advertise a retryable proxy-side fault, which is
+ // wrong and would make callers loop.)
+ if (responseFuture != null) {
+ responseFuture.complete(new ProxyRelayResult<>(ResponseCode.REQUEST_CODE_NOT_SUPPORTED,
+ "gRPC v2 protocol cannot report consumer running info without jstack, "
+ + "retry with jstackEnable=true",
+ null));
+ }
return CompletableFuture.completedFuture(null);
}
this.writeTelemetryCommand(TelemetryCommand.newBuilder()
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java
index 87d20ebca1..695ac137d9 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java
@@ -109,10 +109,11 @@ public class GrpcConverter {
protected Map<String, String> buildUserAttributes(MessageExt messageExt) {
Map<String, String> userAttributes = new HashMap<>();
Map<String, String> properties = messageExt.getProperties();
-
- for (Map.Entry<String, String> property : properties.entrySet()) {
- if (!MessageConst.STRING_HASH_SET.contains(property.getKey())) {
- userAttributes.put(property.getKey(), property.getValue());
+ if (properties != null) {
+ for (Map.Entry<String, String> property : properties.entrySet()) {
+ if (!MessageConst.STRING_HASH_SET.contains(property.getKey())) {
+ userAttributes.put(property.getKey(), property.getValue());
+ }
}
}
@@ -161,7 +162,9 @@ public class GrpcConverter {
}
// message_type
- TopicMessageType topicMessageType = TopicMessageType.parseFromMessageProperty(messageExt.getProperties());
+ Map<String, String> properties = messageExt.getProperties();
+ TopicMessageType topicMessageType = TopicMessageType.parseFromMessageProperty(
+ properties != null ? properties : new HashMap<String, String>());
systemPropertiesBuilder.setMessageType(convertToGrpcMessageType(topicMessageType));
// born_timestamp (millis)
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java b/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java
index a56bc42596..a9ebe83c17 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java
@@ -385,6 +385,10 @@ public class DefaultMessagingProcessor extends AbstractStartAndShutdown implemen
return this.serviceManager.getMetadataService();
}
+ public ServiceManager getServiceManager() {
+ return this.serviceManager;
+ }
+
@Override
public void addReceiptHandle(ProxyContext ctx, Channel channel, String group, String msgID,
MessageReceiptHandle messageReceiptHandle) {
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java
index 89539244f2..703d9431d5 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java
@@ -158,6 +158,7 @@ public class ClusterServiceManager extends AbstractStartAndShutdown implements S
this.appendShutdown(scheduledExecutorService::shutdown);
this.appendStartAndShutdown(this.messagingClientAPIFactory);
this.appendStartAndShutdown(this.operationClientAPIFactory);
+ this.appendStartAndShutdown(this.adminService);
this.appendStartAndShutdown(this.transactionClientAPIFactory);
this.appendStartAndShutdown(this.liteSubscriptionAPIFactory);
this.appendStartAndShutdown(this.topicRouteService);
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java
index 8f5073bb3a..6e7827c4eb 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java
@@ -88,6 +88,7 @@ public class LocalServiceManager extends AbstractStartAndShutdown implements Ser
protected void init() {
this.appendStartAndShutdown(this.mqClientAPIFactory);
this.appendStartAndShutdown(this.topicRouteService);
+ this.appendStartAndShutdown(this.adminService);
this.appendStartAndShutdown(new LocalServiceManagerStartAndShutdown());
}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java
index a9e6686b43..4b8f3f9889 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java
@@ -14,13 +14,35 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
package org.apache.rocketmq.proxy.service.admin;
import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.utils.StartAndShutdown;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+
+public interface AdminService extends StartAndShutdown {
+
+ @Override
+ default void start() {
+ }
-public interface AdminService {
+ @Override
+ default void shutdown() {
+ }
boolean topicExist(String topic);
@@ -29,4 +51,68 @@ public interface AdminService {
boolean createTopicOnBroker(String topic, int wQueueNum, int rQueueNum, List<BrokerData> curBrokerDataList,
List<BrokerData> sampleBrokerDataList, boolean examineTopic, int retryCheckCount) throws Exception;
+
+ // =========================================================================
+ // Admin gateway: broker-facing queries and mutations.
+ //
+ // Every call is delegated to the proxy's OWN managed broker client
+ // (rocketmq-proxy's MQClientAPIFactory), so the admin surface never opens a
+ // direct link to a broker. All methods are asynchronous: the gRPC admin
+ // handlers run on the shared gRPC executor and must not block it, and most
+ // of them fan out to several brokers at once.
+ //
+ // A blank {@code topic} on the statistics methods means "every topic of the
+ // group", which the broker resolves from its own consumer offset table.
+ // =========================================================================
+
+ CompletableFuture<ClusterInfo> getBrokerClusterInfo(long timeoutMillis);
+
+ CompletableFuture<TopicRouteData> getTopicRouteData(String topic);
+
+ CompletableFuture<TopicConfig> getTopicConfig(String brokerAddr, String topic, long timeoutMillis);
+
+ CompletableFuture<TopicStatsTable> getTopicStats(String brokerAddr, String topic, long timeoutMillis);
+
+ CompletableFuture<ConsumeStats> getConsumeStats(String brokerAddr, String group, String topic, long timeoutMillis);
+
+ CompletableFuture<List<QueueTimeSpan>> queryConsumeTimeSpan(String brokerAddr, String topic, String group,
+ long timeoutMillis);
+
+ CompletableFuture<Map<MessageQueue, Long>> resetOffset(String brokerAddr, String topic, String group,
+ long timestamp, boolean isForce, long timeoutMillis);
+
+ CompletableFuture<ConsumerConnection> getConsumerConnectionList(String brokerAddr, String group,
+ long timeoutMillis);
+
+ CompletableFuture<GroupList> queryTopicConsumeByWho(String brokerAddr, String topic, long timeoutMillis);
+
+ CompletableFuture<TopicList> queryTopicsByConsumer(String brokerAddr, String group, long timeoutMillis);
+
+ CompletableFuture<SubscriptionGroupConfig> getSubscriptionGroupConfig(String brokerAddr, String group,
+ long timeoutMillis);
+
+ CompletableFuture<Void> updateSubscriptionGroupConfig(String brokerAddr, SubscriptionGroupConfig config,
+ long timeoutMillis);
+
+ CompletableFuture<Void> deleteSubscriptionGroup(String brokerAddr, String group, boolean cleanOffset,
+ long timeoutMillis);
+
+ /**
+ * Query messages of a topic. When {@code uniqueKey} is true the key is treated as the
+ * client-generated unique message id (what the gRPC protocol exposes as {@code message_id});
+ * otherwise it is matched against the message keys index.
+ *
+ * @param decompressBody inflate a compressed body before returning. Callers that render the
+ * message (QueryMessage) want it inflated; callers that hand the message
+ * back to a client as-is (VerifyMessage) must not, so the body keeps
+ * matching the compression flag the client will act on.
+ */
+ CompletableFuture<List<MessageExt>> queryMessage(String brokerAddr, String topic, String key, int maxNum,
+ long beginTimestamp, long endTimestamp, boolean uniqueKey, boolean decompressBody, long timeoutMillis);
+
+ CompletableFuture<MessageExt> viewMessage(String brokerAddr, String topic, long physicalOffset,
+ long timeoutMillis);
+
+ CompletableFuture<Long> searchOffsetByTimestamp(String brokerAddr, MessageQueue messageQueue, long timestamp,
+ long timeoutMillis);
}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java
index f3c68eab5c..af668ac04f 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java
@@ -20,24 +20,72 @@ package org.apache.rocketmq.proxy.service.admin;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.rocketmq.client.impl.admin.MqClientAdminImpl;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.common.constant.LoggerName;
import org.apache.rocketmq.common.constant.PermName;
-import org.apache.rocketmq.remoting.protocol.route.BrokerData;
-import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.common.topic.TopicValidator;
import org.apache.rocketmq.logging.org.slf4j.Logger;
import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
import org.apache.rocketmq.proxy.service.route.TopicRouteHelper;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.DeleteSubscriptionGroupRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumeStatsRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerConnectionListRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetTopicStatsInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryConsumeTimeSpanRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryMessageRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicConsumeByWhoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicsByConsumerRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ResetOffsetRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ViewMessageRequestHeader;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
public class DefaultAdminService implements AdminService {
private static final Logger log = LoggerFactory.getLogger(LoggerName.PROXY_LOGGER_NAME);
+ private static final long ROUTE_TIMEOUT_MILLIS = Duration.ofSeconds(3).toMillis();
+
private final MQClientAPIFactory mqClientAPIFactory;
+ /**
+ * A few broker requests only have a blocking client method (topic config, subscription group
+ * config, search offset, nameserver route). The admin gateway promises non-blocking calls so
+ * that a handler fanning out to N brokers never parks a gRPC executor thread, so those are
+ * lifted onto this pool. Daemon threads: the pool lives as long as the proxy process and must
+ * not keep it alive on shutdown.
+ */
+ private final ExecutorService blockingCallExecutor = Executors.newFixedThreadPool(
+ Math.max(4, Runtime.getRuntime().availableProcessors()), new ThreadFactory() {
+ private final AtomicInteger seq = new AtomicInteger();
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread thread = new Thread(r, "AdminBlockingCall_" + seq.getAndIncrement());
+ thread.setDaemon(true);
+ return thread;
+ }
+ });
+
public DefaultAdminService(MQClientAPIFactory mqClientAPIFactory) {
this.mqClientAPIFactory = mqClientAPIFactory;
}
@@ -116,7 +164,7 @@ public class DefaultAdminService implements AdminService {
}
try {
- this.getClient().createTopic(addr, TopicValidator.AUTO_CREATE_TOPIC_KEY_TOPIC, topicConfig, Duration.ofSeconds(3).toMillis());
+ this.getClient().createTopic(addr, TopicValidator.AUTO_CREATE_TOPIC_KEY_TOPIC, topicConfig, ROUTE_TIMEOUT_MILLIS);
} catch (Exception e) {
log.error("create topic on broker failed. topic:{}, broker:{}", topicConfig, addr, e);
}
@@ -137,10 +185,179 @@ public class DefaultAdminService implements AdminService {
}
protected TopicRouteData getTopicRouteDataDirectlyFromNameServer(String topic) throws Exception {
- return this.getClient().getTopicRouteInfoFromNameServer(topic, Duration.ofSeconds(3).toMillis());
+ return this.getClient().getTopicRouteInfoFromNameServer(topic, ROUTE_TIMEOUT_MILLIS);
}
protected MQClientAPIExt getClient() {
return this.mqClientAPIFactory.getClient();
}
+
+ protected MqClientAdminImpl getAdmin() {
+ return this.getClient().getMqClientAdmin();
+ }
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void shutdown() {
+ this.blockingCallExecutor.shutdownNow();
+ }
+
+ private <T> CompletableFuture<T> supplyBlocking(java.util.function.Supplier<T> call) {
+ return CompletableFuture.supplyAsync(call, blockingCallExecutor);
+ }
+
+ // =========================================================================
+ // Admin gateway: broker-facing queries and mutations.
+ // Every call goes through the proxy's OWN managed broker client.
+ // =========================================================================
+
+ @Override
+ public CompletableFuture<ClusterInfo> getBrokerClusterInfo(long timeoutMillis) {
+ // a null address makes the remoting client talk to the nameserver
+ return this.getAdmin().getBrokerClusterInfo(null, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<TopicRouteData> getTopicRouteData(String topic) {
+ return supplyBlocking(() -> {
+ try {
+ return this.getTopicRouteDataDirectlyFromNameServer(topic);
+ } catch (Exception e) {
+ throw new java.util.concurrent.CompletionException(e);
+ }
+ });
+ }
+
+ @Override
+ public CompletableFuture<TopicConfig> getTopicConfig(String brokerAddr, String topic, long timeoutMillis) {
+ return supplyBlocking(() -> {
+ try {
+ return this.getClient().getTopicConfig(brokerAddr, topic, timeoutMillis);
+ } catch (Exception e) {
+ throw new java.util.concurrent.CompletionException(e);
+ }
+ });
+ }
+
+ @Override
+ public CompletableFuture<TopicStatsTable> getTopicStats(String brokerAddr, String topic, long timeoutMillis) {
+ GetTopicStatsInfoRequestHeader header = new GetTopicStatsInfoRequestHeader();
+ header.setTopic(topic);
+ return this.getAdmin().getTopicStatsInfo(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<ConsumeStats> getConsumeStats(String brokerAddr, String group, String topic,
+ long timeoutMillis) {
+ GetConsumeStatsRequestHeader header = new GetConsumeStatsRequestHeader();
+ header.setConsumerGroup(group);
+ // a blank topic tells the broker to collect stats over every topic the group has offsets for
+ header.setTopic(topic == null ? "" : topic);
+ return this.getAdmin().getConsumeStats(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<List<QueueTimeSpan>> queryConsumeTimeSpan(String brokerAddr, String topic, String group,
+ long timeoutMillis) {
+ QueryConsumeTimeSpanRequestHeader header = new QueryConsumeTimeSpanRequestHeader();
+ header.setTopic(topic);
+ header.setGroup(group);
+ return this.getAdmin().queryConsumeTimeSpan(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<Map<MessageQueue, Long>> resetOffset(String brokerAddr, String topic, String group,
+ long timestamp, boolean isForce, long timeoutMillis) {
+ ResetOffsetRequestHeader header = new ResetOffsetRequestHeader();
+ header.setTopic(topic);
+ header.setGroup(group);
+ header.setTimestamp(timestamp);
+ header.setForce(isForce);
+ return this.getAdmin().invokeBrokerToResetOffset(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<ConsumerConnection> getConsumerConnectionList(String brokerAddr, String group,
+ long timeoutMillis) {
+ GetConsumerConnectionListRequestHeader header = new GetConsumerConnectionListRequestHeader();
+ header.setConsumerGroup(group);
+ return this.getAdmin().getConsumerConnectionList(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<GroupList> queryTopicConsumeByWho(String brokerAddr, String topic, long timeoutMillis) {
+ QueryTopicConsumeByWhoRequestHeader header = new QueryTopicConsumeByWhoRequestHeader();
+ header.setTopic(topic);
+ return this.getAdmin().queryTopicConsumeByWho(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<TopicList> queryTopicsByConsumer(String brokerAddr, String group, long timeoutMillis) {
+ QueryTopicsByConsumerRequestHeader header = new QueryTopicsByConsumerRequestHeader();
+ header.setGroup(group);
+ return this.getAdmin().queryTopicsByConsumer(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<SubscriptionGroupConfig> getSubscriptionGroupConfig(String brokerAddr, String group,
+ long timeoutMillis) {
+ return supplyBlocking(() -> {
+ try {
+ return this.getClient().getSubscriptionGroupConfig(brokerAddr, group, timeoutMillis);
+ } catch (Exception e) {
+ throw new java.util.concurrent.CompletionException(e);
+ }
+ });
+ }
+
+ @Override
+ public CompletableFuture<Void> updateSubscriptionGroupConfig(String brokerAddr, SubscriptionGroupConfig config,
+ long timeoutMillis) {
+ return this.getAdmin().updateOrCreateSubscriptionGroup(brokerAddr, config, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<Void> deleteSubscriptionGroup(String brokerAddr, String group, boolean cleanOffset,
+ long timeoutMillis) {
+ DeleteSubscriptionGroupRequestHeader header = new DeleteSubscriptionGroupRequestHeader();
+ header.setGroupName(group);
+ header.setCleanOffset(cleanOffset);
+ return this.getAdmin().deleteSubscriptionGroup(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<List<MessageExt>> queryMessage(String brokerAddr, String topic, String key, int maxNum,
+ long beginTimestamp, long endTimestamp, boolean uniqueKey, boolean decompressBody, long timeoutMillis) {
+ QueryMessageRequestHeader header = new QueryMessageRequestHeader();
+ header.setTopic(topic);
+ header.setKey(key);
+ header.setMaxNum(maxNum);
+ header.setBeginTimestamp(beginTimestamp);
+ header.setEndTimestamp(endTimestamp);
+ return this.getAdmin().queryMessage(brokerAddr, uniqueKey, decompressBody, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<MessageExt> viewMessage(String brokerAddr, String topic, long physicalOffset,
+ long timeoutMillis) {
+ ViewMessageRequestHeader header = new ViewMessageRequestHeader();
+ header.setTopic(topic);
+ header.setOffset(physicalOffset);
+ return this.getAdmin().viewMessage(brokerAddr, header, timeoutMillis);
+ }
+
+ @Override
+ public CompletableFuture<Long> searchOffsetByTimestamp(String brokerAddr, MessageQueue messageQueue,
+ long timestamp, long timeoutMillis) {
+ return supplyBlocking(() -> {
+ try {
+ return this.getClient().searchOffset(brokerAddr, messageQueue, timestamp, timeoutMillis);
+ } catch (Exception e) {
+ throw new java.util.concurrent.CompletionException(e);
+ }
+ });
+ }
}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java
index 71ce222a8c..6aed6ff6d9 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java
@@ -26,7 +26,20 @@ import org.apache.rocketmq.remoting.protocol.header.ConsumeMessageDirectlyResult
import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
/**
- * not implement yet
+ * Relay service used when the proxy runs in CLUSTER mode, i.e. there is no co-located broker
+ * whose {@code NettyRemotingAbstract} could receive the relayed answer (that is what
+ * {@link LocalProxyRelayService} does).
+ *
+ * <p>In CLUSTER mode the proxy process itself is the caller: it writes a
+ * {@link ProxyRelayRequest} into a {@link ProxyChannel} and waits on the future carried by that
+ * request. This class therefore hands back a real, non-null relay future (returning {@code null}
+ * used to make {@code GrpcChannelManager#addResponseFuture} store a null and the caller never
+ * observe any result) and bridges it to the caller's future, including the exceptional path and
+ * the {@code GrpcChannelManager#scanExpireResultFuture} timeout completion.
+ *
+ * <p>A plain {@link RemotingCommand} (not a {@link ProxyRelayRequest}) has no in-process waiter;
+ * the relay future is still returned so that the channel implementation can register it for the
+ * client answer, but nobody observes the outcome.
*/
public class ClusterProxyRelayService extends AbstractProxyRelayService {
@@ -38,13 +51,41 @@ public class ClusterProxyRelayService extends AbstractProxyRelayService {
public CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> processGetConsumerRunningInfo(
ProxyContext context, RemotingCommand command,
GetConsumerRunningInfoRequestHeader header) {
- return null;
+ CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> relayFuture = new CompletableFuture<>();
+ bridgeToCaller(command, relayFuture);
+ return relayFuture;
}
@Override
public CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> processConsumeMessageDirectly(
ProxyContext context, RemotingCommand command,
ConsumeMessageDirectlyResultRequestHeader header) {
- return null;
+ CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> relayFuture = new CompletableFuture<>();
+ bridgeToCaller(command, relayFuture);
+ return relayFuture;
+ }
+
+ /**
+ * Propagates the relay outcome to the in-process caller that attached its future to the
+ * command. Both normal and exceptional completions are forwarded so the caller never has to
+ * fall back on its own timeout.
+ */
+ private static <T> void bridgeToCaller(RemotingCommand command,
+ CompletableFuture<ProxyRelayResult<T>> relayFuture) {
+ if (!(command instanceof ProxyRelayRequest)) {
+ return;
+ }
+ CompletableFuture<ProxyRelayResult<T>> callerFuture =
+ ((ProxyRelayRequest) command).typedResponseFuture();
+ if (callerFuture == null) {
+ return;
+ }
+ relayFuture.whenComplete((relayResult, throwable) -> {
+ if (throwable != null) {
+ callerFuture.completeExceptionally(throwable);
+ } else {
+ callerFuture.complete(relayResult);
+ }
+ });
}
}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ProxyRelayRequest.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ProxyRelayRequest.java
new file mode 100644
index 0000000000..982cd2e54d
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ProxyRelayRequest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.proxy.service.relay;
+
+import com.alibaba.fastjson2.annotation.JSONField;
+import java.util.concurrent.CompletableFuture;
+import org.apache.rocketmq.remoting.CommandCustomHeader;
+import org.apache.rocketmq.remoting.protocol.RemotingCommand;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+
+/**
+ * A {@link RemotingCommand} that travels through the in-process relay pipeline
+ * ({@code ProxyChannel#writeAndFlush}) instead of a real Netty socket, and therefore has to
+ * carry the caller's {@link CompletableFuture} along with the wire fields.
+ *
+ * <p>Why this exists: {@code ProxyChannel#writeAndFlush(Object)} can only return a Netty
+ * {@code ChannelFuture}, so an in-process caller (CLUSTER-mode proxy admin) that writes a
+ * {@code GET_CONSUMER_RUNNING_INFO} / {@code CONSUME_MESSAGE_DIRECTLY} command into a
+ * {@code ProxyChannel} has no way to observe the client's answer. The relay future created by
+ * {@link ProxyRelayService} is consumed by the concrete channel implementation (it is handed to
+ * {@code GrpcChannelManager#addResponseFuture}) and never surfaces again. Attaching the caller's
+ * future to the command lets {@link ClusterProxyRelayService} bridge the two.
+ *
+ * <p><b>Payload typing.</b> The future is stored type-erased. The request code fully determines
+ * the payload type and is the only contract between the caller and the relay:
+ * <ul>
+ * <li>{@code RequestCode.GET_CONSUMER_RUNNING_INFO} → a future of {@link ProxyRelayResult}
+ * whose payload is a {@link ConsumerRunningInfo}</li>
+ * <li>{@code RequestCode.CONSUME_MESSAGE_DIRECTLY} → a future of {@link ProxyRelayResult}
+ * whose payload is a {@link ConsumeMessageDirectlyResult}</li>
+ * </ul>
+ *
+ * <p>This command is strictly in-process: it is never encoded onto a socket, so the non-wire
+ * field below is excluded from the fastjson2 view of the command.
+ */
+public class ProxyRelayRequest extends RemotingCommand {
+
+ /**
+ * The in-process caller's future, kept type-erased (see the class javadoc for the code to
+ * payload mapping). Never serialized.
+ */
+ @JSONField(serialize = false)
+ private final transient CompletableFuture<?> responseFuture;
+
+ protected ProxyRelayRequest(CompletableFuture<?> responseFuture) {
+ this.responseFuture = responseFuture;
+ }
+
+ /**
+ * Mirrors {@link RemotingCommand#createRequestCommand(int, CommandCustomHeader)}. The only
+ * difference is that the private {@code customHeader} field of the superclass is populated
+ * through the public {@link #writeCustomHeader(CommandCustomHeader)} hook, so that
+ * {@code ProxyChannel#writeAndFlush} can read it back with {@code readCustomHeader()}.
+ *
+ * @param code {@code RequestCode.GET_CONSUMER_RUNNING_INFO} or
+ * {@code RequestCode.CONSUME_MESSAGE_DIRECTLY}
+ * @param customHeader the matching request header
+ * @param responseFuture the caller's future, typed as documented on the class
+ */
+ public static ProxyRelayRequest createRequestCommand(int code, CommandCustomHeader customHeader,
+ CompletableFuture<?> responseFuture) {
+ ProxyRelayRequest cmd = new ProxyRelayRequest(responseFuture);
+ cmd.setCode(code);
+ cmd.writeCustomHeader(customHeader);
+ setCmdVersion(cmd);
+ return cmd;
+ }
+
+ /**
+ * Type-erased view of the caller's future. Prefer {@link #typedResponseFuture()} unless the
+ * caller only needs to know whether a future was attached at all.
+ */
+ public CompletableFuture<?> getResponseFuture() {
+ return responseFuture;
+ }
+
+ /**
+ * Typed view of the caller's future, e.g.
+ * {@code CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>>}.
+ *
+ * @param <T> the payload type implied by the request code
+ */
+ public <T> CompletableFuture<ProxyRelayResult<T>> typedResponseFuture() {
+ return castResponseFuture(this.responseFuture);
+ }
+
+ /**
+ * The single unavoidable unchecked cast of this design.
+ *
+ * <p>It is safe because both ends of the future live inside the same proxy process and are
+ * written by the same feature: the caller creates
+ * {@code CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>>} (or the
+ * {@link ConsumeMessageDirectlyResult} twin), pairs it with the matching request code in
+ * {@link #createRequestCommand}, and the relay only ever completes it with the payload of the
+ * same {@link ProxyRelayService} method that the request code selected. The command never
+ * crosses a process boundary, so no untrusted producer can violate the pairing.
+ */
+ @SuppressWarnings("unchecked")
+ private static <T> CompletableFuture<ProxyRelayResult<T>> castResponseFuture(CompletableFuture<?> raw) {
+ return (CompletableFuture<ProxyRelayResult<T>>) raw;
+ }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/GrpcServerInterceptorOrderTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/GrpcServerInterceptorOrderTest.java
new file mode 100644
index 0000000000..2c1044342c
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/GrpcServerInterceptorOrderTest.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.rocketmq.proxy.grpc;
+
+import io.grpc.Attributes;
+import io.grpc.Metadata;
+import io.grpc.ServerBuilder;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import java.util.List;
+import org.apache.rocketmq.common.constant.GrpcConstants;
+import org.apache.rocketmq.proxy.grpc.constant.AttributeKeys;
+import org.apache.rocketmq.proxy.grpc.interceptor.HeaderInterceptor;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The admin server authenticates in an interceptor, and authentication reads the channel id from the
+ * request metadata. Since authentication results are cached per channel id, it must never run on the
+ * client-supplied value: a second connection could otherwise present the same id and reuse the first
+ * connection's successful authentication. These tests pin the two properties that together prevent
+ * that — the ordering of the pipeline, and the header normalization it performs.
+ */
+public class GrpcServerInterceptorOrderTest {
+
+ /**
+ * gRPC invokes server interceptors in reverse registration order, so an interceptor that must
+ * run after {@link HeaderInterceptor} has to be registered before it.
+ */
+ @Test
+ public void postHeaderInterceptorsAreOrderedAfterHeaderInterceptorTest() {
+ ServerInterceptor authInterceptor = mock(ServerInterceptor.class);
+ @SuppressWarnings("unchecked")
+ ServerBuilder<?> serverBuilder = mock(ServerBuilder.class);
+
+ GrpcServerBuilder.configureInterceptors(serverBuilder, authInterceptor);
+
+ ArgumentCaptor<ServerInterceptor> captor = ArgumentCaptor.forClass(ServerInterceptor.class);
+ verify(serverBuilder, atLeastOnce()).intercept(captor.capture());
+ List<ServerInterceptor> registrationOrder = captor.getAllValues();
+
+ int authIndex = registrationOrder.indexOf(authInterceptor);
+ int headerIndex = -1;
+ for (int i = 0; i < registrationOrder.size(); i++) {
+ if (registrationOrder.get(i) instanceof HeaderInterceptor) {
+ headerIndex = i;
+ }
+ }
+
+ assertSame(authInterceptor, registrationOrder.get(0));
+ assertTrue("HeaderInterceptor must be registered", headerIndex >= 0);
+ // registered earlier means executed later, so authentication observes normalized headers
+ assertTrue("authentication must be registered before HeaderInterceptor so that it runs after it",
+ authIndex < headerIndex);
+ }
+
+ /**
+ * The normalization the ordering above depends on: whatever channel id the client puts on the
+ * wire is discarded in favour of the one derived from the transport.
+ */
+ @Test
+ public void headerInterceptorReplacesClientSuppliedChannelIdTest() {
+ Metadata headers = new Metadata();
+ headers.put(GrpcConstants.CHANNEL_ID, "forged-by-client");
+
+ ServerCall<Object, Object> call = mock(ServerCall.class);
+ when(call.getAttributes()).thenReturn(Attributes.newBuilder()
+ .set(AttributeKeys.CHANNEL_ID, "transport-channel-id")
+ .build());
+ ServerCallHandler<Object, Object> next = mock(ServerCallHandler.class);
+
+ new HeaderInterceptor().interceptCall(call, headers, next);
+
+ assertEquals("transport-channel-id", headers.get(GrpcConstants.CHANNEL_ID));
+ verify(next).startCall(any(), any());
+ }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverterTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverterTest.java
new file mode 100644
index 0000000000..40608cab5c
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverterTest.java
@@ -0,0 +1,533 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse.GroupAccumulation;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.FilterType;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.MessageQueueItem;
+import apache.rocketmq.v2.MessageType;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import com.alibaba.fastjson2.JSON;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.filter.ExpressionType;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.remoting.protocol.LanguageCode;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeStatus;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.remoting.protocol.body.ProcessQueueInfo;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
+import org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class AdminModelConverterTest {
+
+ private static final String TOPIC = "topicA";
+ private static final String GROUP = "groupA";
+ private static final String BROKER_A = "broker-a";
+ private static final String BROKER_B = "broker-b";
+ private static final String ADDR_A = "127.0.0.1:10911";
+ private static final String ADDR_B = "127.0.0.2:10911";
+
+ // ------------------------------------------------------------------ helpers
+
+ private static OffsetWrapper offsetWrapper(long brokerOffset, long consumerOffset, long pullOffset,
+ long lastTimestamp) {
+ OffsetWrapper wrapper = new OffsetWrapper();
+ wrapper.setBrokerOffset(brokerOffset);
+ wrapper.setConsumerOffset(consumerOffset);
+ wrapper.setPullOffset(pullOffset);
+ wrapper.setLastTimestamp(lastTimestamp);
+ return wrapper;
+ }
+
+ private static ConsumeStats consumeStats(String topic, String brokerName, int queueId, OffsetWrapper wrapper) {
+ ConsumeStats stats = new ConsumeStats();
+ stats.getOffsetTable().put(new MessageQueue(topic, brokerName, queueId), wrapper);
+ return stats;
+ }
+
+ private static Map<String, ConsumeStats> statsByBroker(ConsumeStats... statsList) {
+ Map<String, ConsumeStats> map = new LinkedHashMap<>();
+ for (int i = 0; i < statsList.length; i++) {
+ map.put(i == 0 ? ADDR_A : ADDR_B, statsList[i]);
+ }
+ return map;
+ }
+
+ private static QueueTimeSpan queueTimeSpan(String topic, String brokerName, int queueId, long min, long max,
+ long consume, long delay) {
+ QueueTimeSpan span = new QueueTimeSpan();
+ span.setMessageQueue(new MessageQueue(topic, brokerName, queueId));
+ span.setMinTimeStamp(min);
+ span.setMaxTimeStamp(max);
+ span.setConsumeTimeStamp(consume);
+ span.setDelayTime(delay);
+ return span;
+ }
+
+ // ------------------------------------------------------------------ toAccumulation
+
+ @Test
+ public void toAccumulationSplitsInflightFromReadyTest() {
+ // brokerOffset=100 consumerOffset=60 pullOffset=75:
+ // accumulation = 100-60 = 40, inflight = 75-60 = 15, ready = 40-15 = 25
+ ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 60L, 75L, 0L));
+
+ AdminModelConverter.AccumulationResult result =
+ AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+ assertEquals(40L, result.total.getAccumulation());
+ assertEquals(15L, result.total.getInflightMessages());
+ assertEquals(25L, result.total.getReadyMessages());
+ GroupAccumulation byTopic = result.byTopic.get(TOPIC);
+ assertNotNull(byTopic);
+ assertEquals(40L, byTopic.getAccumulation());
+ assertEquals(15L, byTopic.getInflightMessages());
+ assertEquals(25L, byTopic.getReadyMessages());
+ }
+
+ @Test
+ public void toAccumulationClampsNegativeDiffsToZeroTest() {
+ // consumer ahead of the broker (offset moved back, stats skew): never report negative lag
+ ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(50L, 60L, 40L, 0L));
+
+ AdminModelConverter.AccumulationResult result =
+ AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+ assertEquals(0L, result.total.getAccumulation());
+ assertEquals(0L, result.total.getInflightMessages());
+ assertEquals(0L, result.total.getReadyMessages());
+ assertEquals(0L, result.byTopic.get(TOPIC).getAccumulation());
+ }
+
+ @Test
+ public void toAccumulationCapsInflightAtAccumulationTest() {
+ // pullOffset beyond brokerOffset: inflight must not exceed accumulation, ready stays >= 0
+ ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 90L, 120L, 0L));
+
+ AdminModelConverter.AccumulationResult result =
+ AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+ assertEquals(10L, result.total.getAccumulation());
+ assertEquals(10L, result.total.getInflightMessages());
+ assertEquals(0L, result.total.getReadyMessages());
+ }
+
+ @Test
+ public void toAccumulationFoldsPopRetryTopicIntoNormalTopicTest() {
+ ConsumeStats normal = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 60L, 60L, 0L));
+ ConsumeStats popRetryV1 = consumeStats(KeyBuilder.buildPopRetryTopicV1(TOPIC, GROUP), BROKER_A, 0,
+ offsetWrapper(10L, 5L, 5L, 0L));
+ ConsumeStats popRetryV2 = consumeStats(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP), BROKER_B, 0,
+ offsetWrapper(20L, 5L, 5L, 0L));
+ Map<String, ConsumeStats> byBroker = new LinkedHashMap<>();
+ byBroker.put(ADDR_A, merge(normal, popRetryV1));
+ byBroker.put(ADDR_B, popRetryV2);
+
+ AdminModelConverter.AccumulationResult result = AdminModelConverter.toAccumulation(byBroker, GROUP);
+
+ // 40 (normal) + 5 (pop retry v1) + 15 (pop retry v2)
+ assertEquals(60L, result.total.getAccumulation());
+ assertEquals(1, result.byTopic.size());
+ assertFalse(result.byTopic.containsKey(KeyBuilder.buildPopRetryTopicV1(TOPIC, GROUP)));
+ assertFalse(result.byTopic.containsKey(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP)));
+ assertEquals(60L, result.byTopic.get(TOPIC).getAccumulation());
+ }
+
+ @Test
+ public void toAccumulationCountsPullRetryInTotalButNotInByTopicTest() {
+ String pullRetryTopic = MixAll.getRetryTopic(GROUP);
+ ConsumeStats pullRetry = consumeStats(pullRetryTopic, BROKER_A, 0, offsetWrapper(30L, 10L, 10L, 0L));
+ ConsumeStats normal = consumeStats(TOPIC, BROKER_A, 1, offsetWrapper(100L, 60L, 60L, 0L));
+
+ AdminModelConverter.AccumulationResult result =
+ AdminModelConverter.toAccumulation(statsByBroker(merge(pullRetry, normal)), GROUP);
+
+ // the pull retry backlog is real lag of the group, so it belongs to the total ...
+ assertEquals(60L, result.total.getAccumulation());
+ // ... but %RETRY%<group> is not a user-visible topic, so it must not show up per topic
+ assertEquals(1, result.byTopic.size());
+ assertFalse(result.byTopic.containsKey(pullRetryTopic));
+ assertEquals(40L, result.byTopic.get(TOPIC).getAccumulation());
+ }
+
+ @Test
+ public void toAccumulationSetsLastConsumeTimestampAndDelayTest() {
+ long lastTimestamp = System.currentTimeMillis() - 5000L;
+ ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 60L, 60L, lastTimestamp));
+
+ AdminModelConverter.AccumulationResult result =
+ AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+ assertEquals(lastTimestamp, result.total.getLastConsumeTimestamp());
+ assertTrue(result.total.hasDeliverDelayTime());
+ assertTrue(result.total.getDeliverDelayTime().getSeconds() >= 4L);
+ }
+
+ @Test
+ public void toAccumulationIgnoresTimestampWhenCaughtUpTest() {
+ ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0,
+ offsetWrapper(60L, 60L, 60L, System.currentTimeMillis()));
+
+ AdminModelConverter.AccumulationResult result =
+ AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+ assertEquals(0L, result.total.getAccumulation());
+ assertEquals(0L, result.total.getLastConsumeTimestamp());
+ assertFalse(result.total.hasDeliverDelayTime());
+ }
+
+ @Test
+ public void toAccumulationEmptyInputIsZeroTest() {
+ AdminModelConverter.AccumulationResult result =
+ AdminModelConverter.toAccumulation(Collections.emptyMap(), GROUP);
+
+ assertEquals(0L, result.total.getAccumulation());
+ assertEquals(0L, result.total.getInflightMessages());
+ assertEquals(0L, result.total.getReadyMessages());
+ assertTrue(result.byTopic.isEmpty());
+ }
+
+ private static ConsumeStats merge(ConsumeStats first, ConsumeStats second) {
+ ConsumeStats merged = new ConsumeStats();
+ merged.getOffsetTable().putAll(first.getOffsetTable());
+ merged.getOffsetTable().putAll(second.getOffsetTable());
+ return merged;
+ }
+
+ // ------------------------------------------------------------------ toQueryTimeSpan
+
+ @Test
+ public void toQueryTimeSpanMapsAllFieldsTest() {
+ List<QueueTimeSpan> spans = Collections.singletonList(
+ queueTimeSpan(TOPIC, BROKER_A, 3, 100L, 200L, 150L, 42L));
+
+ QueryTimeSpanResponse response = AdminModelConverter.toQueryTimeSpan(spans);
+
+ assertEquals(Code.OK, response.getStatus().getCode());
+ assertEquals(1, response.getQueueTimeSpanListCount());
+ QueryTimeSpanResponse.QueueTimeSpan span = response.getQueueTimeSpanList(0);
+ assertEquals(TOPIC, span.getMessageQueue().getTopic().getName());
+ assertEquals(BROKER_A, span.getMessageQueue().getBroker().getName());
+ assertEquals(3, span.getMessageQueue().getId());
+ assertEquals(100L, span.getMinTimestamp());
+ assertEquals(200L, span.getMaxTimestamp());
+ assertEquals(150L, span.getConsumeTimestamp());
+ assertEquals(42L, span.getDelayTimeMs());
+ }
+
+ @Test
+ public void toQueryTimeSpanClampsNegativeDelayToZeroTest() {
+ List<QueueTimeSpan> spans = Collections.singletonList(
+ queueTimeSpan(TOPIC, BROKER_A, 0, 100L, 200L, 150L, -5L));
+
+ QueryTimeSpanResponse response = AdminModelConverter.toQueryTimeSpan(spans);
+
+ assertEquals(0L, response.getQueueTimeSpanList(0).getDelayTimeMs());
+ }
+
+ @Test
+ public void toQueryTimeSpanSkipsNullAndHandlesNullListTest() {
+ // a null list is tolerated and yields an OK, empty response
+ QueryTimeSpanResponse nullResponse = AdminModelConverter.toQueryTimeSpan(null);
+ assertEquals(Code.OK, nullResponse.getStatus().getCode());
+ assertEquals(0, nullResponse.getQueueTimeSpanListCount());
+
+ // null / queue-less entries are skipped, only the valid span is mapped
+ List<QueueTimeSpan> spans = new ArrayList<>();
+ spans.add(null);
+ spans.add(new QueueTimeSpan());
+ spans.add(queueTimeSpan(TOPIC, BROKER_A, 0, 1L, 2L, 3L, 4L));
+ QueryTimeSpanResponse response = AdminModelConverter.toQueryTimeSpan(spans);
+ assertEquals(Code.OK, response.getStatus().getCode());
+ assertEquals(1, response.getQueueTimeSpanListCount());
+ }
+
+ // ------------------------------------------------------------------ toMessageType / toTopicStatus
+
+ @Test
+ public void toMessageTypeMapsKnownAndDegradesUnknownTest() {
+ assertEquals(MessageType.NORMAL, AdminModelConverter.toMessageType(TopicMessageType.NORMAL));
+ assertEquals(MessageType.FIFO, AdminModelConverter.toMessageType(TopicMessageType.FIFO));
+ assertEquals(MessageType.DELAY, AdminModelConverter.toMessageType(TopicMessageType.DELAY));
+ assertEquals(MessageType.TRANSACTION, AdminModelConverter.toMessageType(TopicMessageType.TRANSACTION));
+ assertEquals(MessageType.LITE, AdminModelConverter.toMessageType(TopicMessageType.LITE));
+ // MessageType.UNRECOGNIZED cannot be set on a builder, so unknowns must degrade, not blow up
+ for (TopicMessageType type : Arrays.asList(TopicMessageType.MIXED, TopicMessageType.PRIORITY,
+ TopicMessageType.UNSPECIFIED, null)) {
+ MessageType mapped = AdminModelConverter.toMessageType(type);
+ assertEquals(MessageType.MESSAGE_TYPE_UNSPECIFIED, mapped);
+ assertNotEquals(MessageType.UNRECOGNIZED, mapped);
+ }
+ }
+
+ @Test
+ public void toTopicStatusReadsMessageTypeFromAttributesTest() {
+ TopicConfig config = new TopicConfig();
+ config.setTopicName(TOPIC);
+ config.setReadQueueNums(8);
+ config.setWriteQueueNums(16);
+ config.setPerm(6);
+ config.setTopicMessageType(TopicMessageType.FIFO);
+
+ DescribeTopicStatusResponse response = AdminModelConverter.toTopicStatus(config, TOPIC);
+
+ assertEquals(Code.OK, response.getStatus().getCode());
+ assertEquals(MessageType.FIFO, response.getTopicMessageType());
+ assertTrue(response.getDescription().contains("readQueueNums=8"));
+ assertTrue(response.getDescription().contains("writeQueueNums=16"));
+ assertTrue(response.getDescription().contains("perm=6"));
+ }
+
+ @Test
+ public void toTopicStatusFallsBackWhenConfigThinOrMissingTest() {
+ // an empty config (no message-type attribute) defaults to NORMAL
+ DescribeTopicStatusResponse normal = AdminModelConverter.toTopicStatus(new TopicConfig(), TOPIC);
+ assertEquals(MessageType.NORMAL, normal.getTopicMessageType());
+
+ // a null config stays honest: UNSPECIFIED type + an explanatory description
+ DescribeTopicStatusResponse missing = AdminModelConverter.toTopicStatus(null, TOPIC);
+ assertEquals(Code.OK, missing.getStatus().getCode());
+ assertEquals(MessageType.MESSAGE_TYPE_UNSPECIFIED, missing.getTopicMessageType());
+ assertTrue(missing.getDescription().contains("topic config not available for " + TOPIC));
+ }
+
+ // ------------------------------------------------------------------ toTopicRoute / toMessageQueue
+
+ @Test
+ public void toTopicRouteSerializesRouteDataOrEmptyJsonTest() {
+ TopicRouteData routeData = new TopicRouteData();
+ routeData.setOrderTopicConf("orderConf");
+ GetTopicRouteResponse response = AdminModelConverter.toTopicRoute(routeData);
+ assertEquals(Code.OK, response.getStatus().getCode());
+ assertEquals(JSON.toJSONString(routeData), response.getTopicRouteData());
+
+ // a null route serializes to empty JSON rather than the literal "null"
+ GetTopicRouteResponse nullResponse = AdminModelConverter.toTopicRoute(null);
+ assertEquals(Code.OK, nullResponse.getStatus().getCode());
+ assertEquals("{}", nullResponse.getTopicRouteData());
+ }
+
+ @Test
+ public void toMessageQueueSetsBrokerAndIdTest() {
+ apache.rocketmq.v2.MessageQueue v2 =
+ AdminModelConverter.toMessageQueue(new MessageQueue(TOPIC, BROKER_A, 3));
+
+ assertEquals(TOPIC, v2.getTopic().getName());
+ assertEquals(BROKER_A, v2.getBroker().getName());
+ assertEquals(3, v2.getId());
+ }
+
+ // ------------------------------------------------------------------ clients
+
+ @Test
+ public void toClientInfoParsesHostnameAndPlainAddrTest() {
+ // "hostname@ip:port" splits into hostname + egress ip; message model carried through
+ Connection withHost = new Connection();
+ withHost.setClientId("CID-1");
+ withHost.setClientAddr("consumer-host@192.168.1.5:43210");
+ withHost.setLanguage(LanguageCode.JAVA);
+ withHost.setVersion(355);
+ apache.rocketmq.v2.ClientInfo hostInfo = AdminModelConverter.toClientInfo(withHost,
+ apache.rocketmq.v2.MessageModel.CLUSTERING);
+ assertEquals("CID-1", hostInfo.getClientId());
+ assertEquals("JAVA", hostInfo.getLanguage());
+ assertEquals("355", hostInfo.getVersion());
+ assertEquals("192.168.1.5", hostInfo.getEgressIp());
+ assertEquals("consumer-host", hostInfo.getHostname());
+ assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING, hostInfo.getMessageModel());
+
+ // a plain "ip:port" yields no hostname; null message model degrades to UNSPECIFIED
+ Connection plain = new Connection();
+ plain.setClientId("CID-2");
+ plain.setClientAddr("192.168.1.5:43210");
+ apache.rocketmq.v2.ClientInfo plainInfo = AdminModelConverter.toClientInfo(plain, null);
+ assertEquals("192.168.1.5", plainInfo.getEgressIp());
+ assertEquals("", plainInfo.getHostname());
+ assertEquals(apache.rocketmq.v2.MessageModel.MESSAGE_MODEL_UNSPECIFIED, plainInfo.getMessageModel());
+ }
+
+ @Test
+ public void toMessageModelMapsValuesTest() {
+ assertEquals(apache.rocketmq.v2.MessageModel.BROADCASTING,
+ AdminModelConverter.toMessageModel(MessageModel.BROADCASTING));
+ assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING,
+ AdminModelConverter.toMessageModel(MessageModel.CLUSTERING));
+ assertEquals(apache.rocketmq.v2.MessageModel.MESSAGE_MODEL_UNSPECIFIED,
+ AdminModelConverter.toMessageModel(null));
+ }
+
+ @Test
+ public void toFilterExpressionMapsSqlAndTagTest() {
+ apache.rocketmq.v2.FilterExpression sql =
+ AdminModelConverter.toFilterExpression(ExpressionType.SQL92, "a > 1");
+ assertEquals(FilterType.SQL, sql.getType());
+ assertEquals("a > 1", sql.getExpression());
+
+ apache.rocketmq.v2.FilterExpression tag = AdminModelConverter.toFilterExpression(ExpressionType.TAG, "tagA");
+ assertEquals(FilterType.TAG, tag.getType());
+ assertEquals("tagA", tag.getExpression());
+
+ apache.rocketmq.v2.FilterExpression nullExpression = AdminModelConverter.toFilterExpression(null, null);
+ assertEquals(FilterType.TAG, nullExpression.getType());
+ assertEquals("", nullExpression.getExpression());
+ }
+
+ @Test
+ public void toConsumerRunningInfoMapsAllSectionsTest() {
+ ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+ Properties properties = runningInfo.getProperties();
+ properties.setProperty(ConsumerRunningInfo.PROP_CLIENT_VERSION, "V5_0_0");
+ properties.setProperty(ConsumerRunningInfo.PROP_CONSUME_ORDERLY, "false");
+
+ SubscriptionData tagSub = new SubscriptionData();
+ tagSub.setTopic(TOPIC);
+ tagSub.setSubString("tagA");
+ tagSub.setExpressionType(ExpressionType.TAG);
+ SubscriptionData sqlSub = new SubscriptionData();
+ sqlSub.setTopic("topicB");
+ sqlSub.setSubString("a > 1");
+ sqlSub.setExpressionType(ExpressionType.SQL92);
+ runningInfo.getSubscriptionSet().add(tagSub);
+ runningInfo.getSubscriptionSet().add(sqlSub);
+
+ ProcessQueueInfo processQueueInfo = new ProcessQueueInfo();
+ processQueueInfo.setCommitOffset(10L);
+ processQueueInfo.setCachedMsgMinOffset(5L);
+ processQueueInfo.setCachedMsgMaxOffset(15L);
+ processQueueInfo.setCachedMsgCount(7);
+ processQueueInfo.setCachedMsgSizeInMiB(2);
+ processQueueInfo.setTransactionMsgMinOffset(1L);
+ processQueueInfo.setTransactionMsgMaxOffset(3L);
+ processQueueInfo.setTransactionMsgCount(4);
+ processQueueInfo.setLocked(true);
+ processQueueInfo.setTryUnlockTimes(9L);
+ processQueueInfo.setLastLockTimestamp(111L);
+ processQueueInfo.setDroped(false);
+ processQueueInfo.setLastPullTimestamp(222L);
+ processQueueInfo.setLastConsumeTimestamp(333L);
+ runningInfo.getMqTable().put(new MessageQueue(TOPIC, BROKER_A, 0), processQueueInfo);
+
+ ConsumeStatus consumeStatus = new ConsumeStatus();
+ consumeStatus.setPullRT(1.5D);
+ consumeStatus.setPullTPS(2.5D);
+ consumeStatus.setConsumeRT(3.5D);
+ consumeStatus.setConsumeOKTPS(4.5D);
+ consumeStatus.setConsumeFailedTPS(5.5D);
+ consumeStatus.setConsumeFailedMsgs(6L);
+ runningInfo.getStatusTable().put(TOPIC, consumeStatus);
+
+ apache.rocketmq.v2.ConsumerRunningInfo v2 = AdminModelConverter.toConsumerRunningInfo(runningInfo);
+
+ // 1. properties
+ assertEquals("V5_0_0", v2.getPropertiesMap().get(ConsumerRunningInfo.PROP_CLIENT_VERSION));
+ assertEquals("false", v2.getPropertiesMap().get(ConsumerRunningInfo.PROP_CONSUME_ORDERLY));
+ // 2. subscriptions
+ assertEquals(2, v2.getSubscriptionsCount());
+ assertEquals(FilterType.TAG, v2.getSubscriptionsMap().get(TOPIC).getType());
+ assertEquals("tagA", v2.getSubscriptionsMap().get(TOPIC).getExpression());
+ assertEquals(FilterType.SQL, v2.getSubscriptionsMap().get("topicB").getType());
+ assertEquals("a > 1", v2.getSubscriptionsMap().get("topicB").getExpression());
+ // 3. message queue table
+ assertEquals(1, v2.getMessageQueueTableCount());
+ MessageQueueItem item = v2.getMessageQueueTable(0);
+ assertEquals(TOPIC, item.getMessageQueue().getTopic().getName());
+ assertEquals(BROKER_A, item.getMessageQueue().getBroker().getName());
+ assertEquals(0, item.getMessageQueue().getId());
+ apache.rocketmq.v2.ProcessQueueInfo pqi = item.getProcessQueueInfo();
+ assertEquals(10L, pqi.getCommitOffset());
+ assertEquals(5L, pqi.getCachedMsgMinOffset());
+ assertEquals(15L, pqi.getCachedMsgMaxOffset());
+ assertEquals(7, pqi.getCachedMsgCount());
+ assertEquals(2, pqi.getCachedMsgSizeInMib());
+ assertEquals(1L, pqi.getTransactionMsgMinOffset());
+ assertEquals(3L, pqi.getTransactionMsgMaxOffset());
+ assertEquals(4, pqi.getTransactionMsgCount());
+ assertTrue(pqi.getLocked());
+ assertEquals(9L, pqi.getTryUnlockTimes());
+ assertEquals(111L, pqi.getLastLockTimestamp());
+ assertFalse(pqi.getDropped());
+ assertEquals(222L, pqi.getLastPullTimestamp());
+ assertEquals(333L, pqi.getLastConsumeTimestamp());
+ // 4. consume status table
+ assertEquals(1, v2.getConsumeStatusTableCount());
+ apache.rocketmq.v2.ConsumeStatus status = v2.getConsumeStatusTableMap().get(TOPIC);
+ assertNotNull(status);
+ assertEquals(1.5D, status.getReceiveRt(), 0.0001D);
+ assertEquals(2.5D, status.getReceiveTps(), 0.0001D);
+ assertEquals(3.5D, status.getConsumeRt(), 0.0001D);
+ assertEquals(4.5D, status.getConsumeOkTps(), 0.0001D);
+ assertEquals(5.5D, status.getConsumeFailedTps(), 0.0001D);
+ assertEquals(6L, status.getConsumeFailedMsgs());
+ }
+
+ @Test
+ public void toConsumerRunningInfoNullReturnsEmptyTest() {
+ apache.rocketmq.v2.ConsumerRunningInfo v2 = AdminModelConverter.toConsumerRunningInfo(null);
+
+ assertEquals(0, v2.getPropertiesCount());
+ assertEquals(0, v2.getSubscriptionsCount());
+ assertEquals(0, v2.getMessageQueueTableCount());
+ assertEquals(0, v2.getConsumeStatusTableCount());
+ }
+
+ // ------------------------------------------------------------------ misc
+
+ @Test
+ public void toAccumulationSkipsNullStatsEntriesTest() {
+ Map<String, ConsumeStats> byBroker = new HashMap<>();
+ byBroker.put(ADDR_A, null);
+ ConsumeStats withNulls = new ConsumeStats();
+ // the default offsetTable is a ConcurrentHashMap which forbids null values, so install a
+ // plain map to exercise the converter's null guard (deserialized stats can contain one)
+ Map<MessageQueue, OffsetWrapper> offsetTable = new HashMap<>();
+ offsetTable.put(new MessageQueue(TOPIC, BROKER_A, 0), null);
+ offsetTable.put(new MessageQueue(TOPIC, BROKER_A, 1), offsetWrapper(10L, 4L, 4L, 0L));
+ withNulls.setOffsetTable(offsetTable);
+ byBroker.put(ADDR_B, withNulls);
+
+ AdminModelConverter.AccumulationResult result = AdminModelConverter.toAccumulation(byBroker, GROUP);
+
+ assertEquals(6L, result.total.getAccumulation());
+ assertEquals(6L, result.byTopic.get(TOPIC).getAccumulation());
+ }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptorTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptorTest.java
new file mode 100644
index 0000000000..c44038a727
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptorTest.java
@@ -0,0 +1,269 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.proxy.grpc.admin;
+
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.Status;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import org.apache.rocketmq.auth.config.AuthConfig;
+import org.apache.rocketmq.common.action.Action;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.InitConfigTest;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ProxyAdminAuthInterceptorTest extends InitConfigTest {
+
+ @Mock
+ private MessagingProcessor messagingProcessor;
+
+ private static final MethodDescriptor.Marshaller<byte[]> BYTE_MARSHALLER =
+ new MethodDescriptor.Marshaller<byte[]>() {
+ @Override
+ public InputStream stream(byte[] value) {
+ return new ByteArrayInputStream(value == null ? new byte[0] : value);
+ }
+
+ @Override
+ public byte[] parse(InputStream stream) {
+ return new byte[0];
+ }
+ };
+
+ private static MethodDescriptor<byte[], byte[]> method(String name) {
+ return MethodDescriptor.<byte[], byte[]>newBuilder()
+ .setType(MethodDescriptor.MethodType.UNARY)
+ .setFullMethodName("apache.rocketmq.v2.Admin/" + name)
+ .setRequestMarshaller(BYTE_MARSHALLER)
+ .setResponseMarshaller(BYTE_MARSHALLER)
+ .build();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ServerCall<byte[], byte[]> serverCall(String methodName) {
+ ServerCall<byte[], byte[]> call = mock(ServerCall.class);
+ when(call.getMethodDescriptor()).thenReturn(method(methodName));
+ return call;
+ }
+
+ // ---------------------------------------------------------------------
+ // per-method resource / action mapping (read-only vs high-privilege isolation)
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void everyAdminMethodMapsToExpectedResourceAndAction() {
+ // the full per-method ACL table: resource module + action. Read-only RPCs use GET/LIST,
+ // high-privilege mutations use UPDATE/DELETE/PUB, and each is scoped to its own resource.
+ Map<String, ProxyAdminAuthInterceptor.ResourceAction> expected = new LinkedHashMap<>();
+ expected.put("GetProxyRuntimeStats", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.GET));
+ expected.put("GetTopicRoute", ra(ProxyAdminAuthInterceptor.RESOURCE_ROUTE, Action.GET));
+ expected.put("DescribeTopicStatus", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.GET));
+ expected.put("ListSubscription", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.LIST));
+ expected.put("DescribeSubscription", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+ expected.put("ListConsumerConnection", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.LIST));
+ expected.put("DescribeGroupAccumulation", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+ expected.put("GetConsumerRunningInfo", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+ expected.put("QueryTimeSpan", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+ expected.put("QueryMessage", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.GET));
+ expected.put("ChangeLogLevel", ra(ProxyAdminAuthInterceptor.RESOURCE_CONFIG, Action.UPDATE));
+ expected.put("DeleteSubscription", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.DELETE));
+ expected.put("ResetGroupOffset", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.UPDATE));
+ expected.put("AdminSendMessage", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.PUB));
+ expected.put("PrintThreadStackTrace", ra(ProxyAdminAuthInterceptor.RESOURCE_CONNECTION, Action.UPDATE));
+ expected.put("VerifyMessage", ra(ProxyAdminAuthInterceptor.RESOURCE_CONNECTION, Action.UPDATE));
+
+ Set<String> resources = new HashSet<>();
+ for (Map.Entry<String, ProxyAdminAuthInterceptor.ResourceAction> e : expected.entrySet()) {
+ ProxyAdminAuthInterceptor.ResourceAction actual =
+ ProxyAdminAuthInterceptor.resolveResourceAction(e.getKey());
+ assertNotNull("missing mapping for " + e.getKey(), actual);
+ assertEquals("resource for " + e.getKey(), e.getValue().resource, actual.resource);
+ assertEquals("action for " + e.getKey(), e.getValue().action, actual.action);
+ resources.add(actual.resource);
+ }
+ // five distinct modules, all under the proxy.admin.* namespace
+ assertEquals(5, resources.size());
+ for (String resource : resources) {
+ assertTrue(resource.startsWith("proxy.admin."));
+ }
+ }
+
+ private static ProxyAdminAuthInterceptor.ResourceAction ra(String resource, Action action) {
+ return new ProxyAdminAuthInterceptor.ResourceAction(resource, action);
+ }
+
+ // ---------------------------------------------------------------------
+ // behavior modes
+ // ---------------------------------------------------------------------
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void openModePassesThroughWhenClusterAuthDisabled() {
+ AuthConfig authConfig = new AuthConfig();
+ authConfig.setAuthenticationEnabled(false);
+ authConfig.setAuthorizationEnabled(false);
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+
+ ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+ ServerCall<byte[], byte[]> call = serverCall("ListConsumerConnection");
+ ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+ interceptor.interceptCall(call, new Metadata(), next);
+ verify(next).startCall(any(), any());
+ verify(call, never()).close(any(), any());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void failClosedRejectsWhenRequireAuthButClusterAuthDisabled() {
+ AuthConfig authConfig = new AuthConfig();
+ authConfig.setAuthenticationEnabled(false);
+ authConfig.setAuthorizationEnabled(false);
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(true);
+ try {
+ ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+ ServerCall<byte[], byte[]> call = serverCall("ListClients");
+ ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+ interceptor.interceptCall(call, new Metadata(), next);
+ verify(next, never()).startCall(any(), any());
+ org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+ verify(call).close(statusCaptor.capture(), any(Metadata.class));
+ assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+ assertTrue(statusCaptor.getValue().getDescription().contains("grpcAdminServerAuthEnable"));
+ } finally {
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void failClosedRejectsWhenRequireAuthButAuthorizationDisabled() {
+ // authentication on but authorization off: the ACL evaluator is globally gated off, so
+ // enforcement would silently pass. Fail-closed must refuse instead of serving an
+ // unauthorized (potentially destructive) admin RPC to any authenticated identity.
+ AuthConfig authConfig = new AuthConfig();
+ authConfig.setAuthenticationEnabled(true);
+ authConfig.setAuthorizationEnabled(false);
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(true);
+ try {
+ ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+ ServerCall<byte[], byte[]> call = serverCall("DeleteSubscription");
+ ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+ interceptor.interceptCall(call, new Metadata(), next);
+ verify(next, never()).startCall(any(), any());
+ org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+ verify(call).close(statusCaptor.capture(), any(Metadata.class));
+ assertEquals(Status.Code.FAILED_PRECONDITION, statusCaptor.getValue().getCode());
+ assertTrue(statusCaptor.getValue().getDescription().contains("authorizationEnabled"));
+ } finally {
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void failClosedRejectsAnonymousWhenRequireAuth() {
+ AuthConfig authConfig = new AuthConfig();
+ authConfig.setAuthenticationEnabled(true);
+ authConfig.setAuthorizationEnabled(true);
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(true);
+ try {
+ ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+ ServerCall<byte[], byte[]> call = serverCall("PrintThreadStackTrace");
+ ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+ // empty metadata: no credentials at all
+ ServerCall.Listener<byte[]> listener = interceptor.interceptCall(call, new Metadata(), next);
+ assertNotNull(listener);
+ verify(next, never()).startCall(any(), any());
+ org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+ verify(call).close(statusCaptor.capture(), any(Metadata.class));
+ assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+ } finally {
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void authenticationFailureClosesUnauthenticated() {
+ // cluster authentication on + no credentials -> evaluator throws AuthenticationException.
+ // AuthenticationFactory caches evaluators per configName, so use a unique name to get
+ // a fresh evaluator bound to THIS config (otherwise a stale one from another test wins).
+ AuthConfig authConfig = new AuthConfig();
+ authConfig.setConfigName("proxy-admin-auth-failure-test-" + System.nanoTime());
+ authConfig.setAuthenticationEnabled(true);
+ authConfig.setAuthorizationEnabled(false);
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+
+ ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+ ServerCall<byte[], byte[]> call = serverCall("ListConsumerConnection");
+ ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+ interceptor.interceptCall(call, new Metadata(), next);
+ verify(next, never()).startCall(any(), any());
+ org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+ verify(call).close(statusCaptor.capture(), any(Metadata.class));
+ assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+ assertNotNull(statusCaptor.getValue().getDescription());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void authorizationEnabledRejectsMissingCredentials() {
+ // authorization on without authentication -> mapped method demands credentials first
+ AuthConfig authConfig = new AuthConfig();
+ authConfig.setConfigName("proxy-admin-authz-only-test-" + System.nanoTime());
+ authConfig.setAuthenticationEnabled(false);
+ authConfig.setAuthorizationEnabled(true);
+ ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+
+ ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+ ServerCall<byte[], byte[]> call = serverCall("GetTopicRoute");
+ ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+ interceptor.interceptCall(call, new Metadata(), next);
+ verify(next, never()).startCall(any(), any());
+ org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+ verify(call).close(statusCaptor.capture(), any(Metadata.class));
+ assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+ assertTrue(statusCaptor.getValue().getDescription().contains("missing credentials"));
+ }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcServiceTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcServiceTest.java
new file mode 100644
index 0000000000..52362b2884
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcServiceTest.java
@@ -0,0 +1,1495 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.AdminSendMessageRequest;
+import apache.rocketmq.v2.AdminSendMessageResponse;
+import apache.rocketmq.v2.ChangeLogLevelRequest;
+import apache.rocketmq.v2.ChangeLogLevelResponse;
+import apache.rocketmq.v2.ClientType;
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.DeleteSubscriptionRequest;
+import apache.rocketmq.v2.DeleteSubscriptionResponse;
+import apache.rocketmq.v2.DescribeGroupAccumulationRequest;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse;
+import apache.rocketmq.v2.DescribeSubscriptionRequest;
+import apache.rocketmq.v2.DescribeSubscriptionResponse;
+import apache.rocketmq.v2.DescribeTopicStatusRequest;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.FilterExpression;
+import apache.rocketmq.v2.FilterType;
+import apache.rocketmq.v2.GetConsumerRunningInfoRequest;
+import apache.rocketmq.v2.GetConsumerRunningInfoResponse;
+import apache.rocketmq.v2.GetProxyRuntimeStatsRequest;
+import apache.rocketmq.v2.GetProxyRuntimeStatsResponse;
+import apache.rocketmq.v2.GetTopicRouteRequest;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.ListConsumerConnectionRequest;
+import apache.rocketmq.v2.ListConsumerConnectionResponse;
+import apache.rocketmq.v2.ListMessageRequest;
+import apache.rocketmq.v2.ListMessageResponse;
+import apache.rocketmq.v2.ListSubscriptionRequest;
+import apache.rocketmq.v2.ListSubscriptionResponse;
+import apache.rocketmq.v2.MessageType;
+import apache.rocketmq.v2.PrintThreadStackTraceRequest;
+import apache.rocketmq.v2.PrintThreadStackTraceResponse;
+import apache.rocketmq.v2.QueryTimeSpanRequest;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import apache.rocketmq.v2.ResetGroupOffsetRequest;
+import apache.rocketmq.v2.ResetGroupOffsetResponse;
+import apache.rocketmq.v2.Resource;
+import apache.rocketmq.v2.Settings;
+import apache.rocketmq.v2.Subscription;
+import apache.rocketmq.v2.SubscriptionEntry;
+import apache.rocketmq.v2.SubscriptionInfo;
+import apache.rocketmq.v2.SystemProperties;
+import apache.rocketmq.v2.VerifyMessageRequest;
+import apache.rocketmq.v2.VerifyMessageResponse;
+import com.alibaba.fastjson2.JSON;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.Timestamp;
+import io.grpc.stub.StreamObserver;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import org.apache.rocketmq.broker.client.ClientChannelInfo;
+import org.apache.rocketmq.broker.client.ConsumerManager;
+import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MQVersion;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.filter.ExpressionType;
+import org.apache.rocketmq.common.message.Message;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.InitConfigTest;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcChannelManager;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcClientChannel;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+import org.apache.rocketmq.proxy.service.ServiceManager;
+import org.apache.rocketmq.proxy.service.admin.AdminService;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayRequest;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
+import org.apache.rocketmq.proxy.service.route.AddressableMessageQueue;
+import org.apache.rocketmq.proxy.service.route.MessageQueueSelector;
+import org.apache.rocketmq.proxy.service.route.MessageQueueView;
+import org.apache.rocketmq.proxy.service.route.TopicRouteService;
+import org.apache.rocketmq.remoting.protocol.LanguageCode;
+import org.apache.rocketmq.remoting.protocol.RequestCode;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
+import org.apache.rocketmq.remoting.protocol.body.CMResult;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.ConsumeMessageDirectlyResultRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.heartbeat.ConsumeType;
+import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
+import org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SimpleSubscriptionData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ProxyAdminGrpcServiceTest extends InitConfigTest {
+
+ @Mock
+ private ServiceManager serviceManager;
+ @Mock
+ private MessagingProcessor messagingProcessor;
+ @Mock
+ private GrpcChannelManager grpcChannelManager;
+ @Mock
+ private GrpcClientSettingsManager grpcClientSettingsManager;
+ @Mock
+ private ProxyAdminForwarder forwarder;
+ @Mock
+ private AdminService adminService;
+ @Mock
+ private TopicRouteService topicRouteService;
+ @Mock
+ private ConsumerManager consumerManager;
+ @Mock
+ private GrpcClientChannel channel;
+
+ private ProxyAdminGrpcService service;
+
+ private static final String TOPIC = "topicA";
+ private static final String GROUP = "groupA";
+ private static final String CLIENT_ID = "client-1";
+ private static final String ADDRESS_A = "127.0.0.1:10911";
+ private static final String ADDRESS_B = "127.0.0.2:10911";
+
+ @Before
+ public void setUp() {
+ when(serviceManager.getAdminService()).thenReturn(adminService);
+ when(serviceManager.getTopicRouteService()).thenReturn(topicRouteService);
+ service = new ProxyAdminGrpcService(serviceManager, messagingProcessor, grpcChannelManager,
+ grpcClientSettingsManager, forwarder);
+ }
+
+ @After
+ public void tearDown() {
+ service.shutdown();
+ }
+
+ // ------------------------------------------------------------------ helpers
+
+ private static class SimpleObserver<T> implements StreamObserver<T> {
+ T value;
+ Throwable error;
+ boolean completed;
+
+ @Override
+ public void onNext(T value) {
+ this.value = value;
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ this.error = t;
+ }
+
+ @Override
+ public void onCompleted() {
+ this.completed = true;
+ }
+ }
+
+ private static <T> CompletableFuture<T> failedFuture(Throwable t) {
+ CompletableFuture<T> future = new CompletableFuture<>();
+ future.completeExceptionally(t);
+ return future;
+ }
+
+ private void stubClusterInfo(String... addrs) {
+ Map<String, BrokerData> table = new HashMap<>();
+ for (int i = 0; i < addrs.length; i++) {
+ HashMap<Long, String> brokerAddrs = new HashMap<>();
+ brokerAddrs.put(MixAll.MASTER_ID, addrs[i]);
+ table.put("broker-" + i, new BrokerData("DefaultCluster", "broker-" + i, brokerAddrs));
+ }
+ ClusterInfo clusterInfo = new ClusterInfo();
+ clusterInfo.setBrokerAddrTable(table);
+ when(adminService.getBrokerClusterInfo(anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(clusterInfo));
+ }
+
+ private void stubRoute(boolean forWrite, String... brokerAddrs) throws Exception {
+ List<AddressableMessageQueue> queues = new ArrayList<>();
+ for (int i = 0; i < brokerAddrs.length; i++) {
+ queues.add(new AddressableMessageQueue(new MessageQueue(TOPIC, "broker-" + i, i), brokerAddrs[i]));
+ }
+ MessageQueueSelector selector = mock(MessageQueueSelector.class);
+ when(selector.getQueues()).thenReturn(queues);
+ MessageQueueView view = mock(MessageQueueView.class);
+ if (forWrite) {
+ when(view.getWriteSelector()).thenReturn(selector);
+ } else {
+ when(view.getReadSelector()).thenReturn(selector);
+ }
+ when(topicRouteService.getAllMessageQueueView(any(), anyString())).thenReturn(view);
+ }
+
+ private SubscriptionData subscriptionData(String topic, String expressionType, String expression) {
+ SubscriptionData data = new SubscriptionData();
+ data.setTopic(topic);
+ data.setExpressionType(expressionType);
+ data.setSubString(expression);
+ return data;
+ }
+
+ private ConsumerConnection consumerConnection(String clientId, String clientAddr, String... topics) {
+ ConsumerConnection connection = new ConsumerConnection();
+ Connection conn = new Connection();
+ conn.setClientId(clientId);
+ conn.setClientAddr(clientAddr);
+ conn.setLanguage(LanguageCode.JAVA);
+ conn.setVersion(355);
+ connection.getConnectionSet().add(conn);
+ for (String topic : topics) {
+ connection.getSubscriptionTable().put(topic, subscriptionData(topic, ExpressionType.TAG, "tagA"));
+ }
+ connection.setMessageModel(MessageModel.CLUSTERING);
+ connection.setConsumeType(ConsumeType.CONSUME_PASSIVELY);
+ return connection;
+ }
+
+ private Settings settings(ClientType clientType, String group, String topic, String expression) {
+ return Settings.newBuilder()
+ .setClientType(clientType)
+ .setSubscription(Subscription.newBuilder()
+ .setGroup(Resource.newBuilder().setName(group).build())
+ .addSubscriptions(SubscriptionEntry.newBuilder()
+ .setTopic(Resource.newBuilder().setName(topic).build())
+ .setExpression(FilterExpression.newBuilder()
+ .setType(FilterType.TAG).setExpression(expression).build())
+ .build())
+ .build())
+ .build();
+ }
+
+ private MessageExt messageExt(String msgId) {
+ MessageExt ext = new MessageExt();
+ ext.setTopic(TOPIC);
+ ext.setMsgId(msgId);
+ ext.setBody("payload".getBytes(StandardCharsets.UTF_8));
+ ext.setQueueId(0);
+ ext.setQueueOffset(100L);
+ ext.setBrokerName("broker-0");
+ long now = System.currentTimeMillis();
+ ext.setBornTimestamp(now);
+ ext.setStoreTimestamp(now);
+ ext.setBornHost(new InetSocketAddress("127.0.0.1", 10909));
+ ext.setStoreHost(new InetSocketAddress("127.0.0.1", 10911));
+ return ext;
+ }
+
+ private ConsumeStats consumeStats(String topic, long brokerOffset, long consumerOffset, long pullOffset) {
+ ConsumeStats stats = new ConsumeStats();
+ OffsetWrapper wrapper = new OffsetWrapper();
+ wrapper.setBrokerOffset(brokerOffset);
+ wrapper.setConsumerOffset(consumerOffset);
+ wrapper.setPullOffset(pullOffset);
+ stats.getOffsetTable().put(new MessageQueue(topic, "broker-0", 0), wrapper);
+ return stats;
+ }
+
+ private TopicConfig topicConfig(TopicMessageType type, int readQueueNums, int writeQueueNums) {
+ TopicConfig config = new TopicConfig();
+ config.setTopicName(TOPIC);
+ config.setReadQueueNums(readQueueNums);
+ config.setWriteQueueNums(writeQueueNums);
+ config.setPerm(6);
+ if (type != null) {
+ config.setTopicMessageType(type);
+ }
+ return config;
+ }
+
+ /** Registers the client as a local channel reachable through the consumer manager. */
+ private void stubLocalClient() {
+ when(serviceManager.getConsumerManager()).thenReturn(consumerManager);
+ when(consumerManager.findChannel(GROUP, CLIENT_ID))
+ .thenReturn(new ClientChannelInfo(channel, CLIENT_ID, LanguageCode.JAVA, 0));
+ when(channel.isActive()).thenReturn(true);
+ }
+
+ /**
+ * Simulates the relay round trip: when the service writes a {@link ProxyRelayRequest} into the
+ * client channel, the captured caller future is completed with the given result, exactly like
+ * {@code ClusterProxyRelayService} + the channel implementation would do in production.
+ */
+ private <T> void answerRelayWith(int expectedCode, ProxyRelayResult<T> result) {
+ when(channel.writeAndFlush(any())).thenAnswer(invocation -> {
+ ProxyRelayRequest request = invocation.getArgument(0);
+ assertEquals(expectedCode, request.getCode());
+ CompletableFuture<ProxyRelayResult<T>> future = request.typedResponseFuture();
+ future.complete(result);
+ return null;
+ });
+ }
+
+ private static Resource resource(String name) {
+ return Resource.newBuilder().setName(name).build();
+ }
+
+ // ------------------------------------------------------------------ 1. ChangeLogLevel
+
+ @Test
+ public void changeLogLevelChangesRootLevelTest() {
+ SimpleObserver<ChangeLogLevelResponse> observer = new SimpleObserver<>();
+ service.changeLogLevel(ChangeLogLevelRequest.newBuilder()
+ .setLevel(ChangeLogLevelRequest.Level.DEBUG).build(), observer);
+
+ assertNotNull(observer.value);
+ assertTrue(observer.value.getRemark(), observer.value.getRemark().contains("log level changed"));
+ assertTrue(observer.completed);
+ }
+
+ // ------------------------------------------------------------------ 2. DescribeTopicStatus
+
+ @Test
+ public void describeTopicStatusReadsMessageTypeFromAttributesTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ when(adminService.getTopicConfig(anyString(), eq(TOPIC), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.FIFO, 4, 4)));
+
+ SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+ service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+ .setTopic(resource(TOPIC)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(MessageType.FIFO, observer.value.getTopicMessageType());
+ assertTrue(observer.value.getDescription().contains("readQueueNums=4"));
+ }
+
+ @Test
+ public void describeTopicStatusMergesQueueNumsAcrossBrokersTest() throws Exception {
+ stubRoute(false, ADDRESS_A, ADDRESS_B);
+ when(adminService.getTopicConfig(anyString(), eq(TOPIC), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.FIFO, 4, 4)));
+
+ SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+ service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+ .setTopic(resource(TOPIC)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(MessageType.FIFO, observer.value.getTopicMessageType());
+ assertTrue(observer.value.getDescription().contains("readQueueNums=8"));
+ assertTrue(observer.value.getDescription().contains("writeQueueNums=8"));
+ }
+
+ @Test
+ public void describeTopicStatusReportsMixedWhenBrokersDisagreeTest() throws Exception {
+ stubRoute(false, ADDRESS_A, ADDRESS_B);
+ when(adminService.getTopicConfig(eq(ADDRESS_A), eq(TOPIC), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.FIFO, 4, 4)));
+ when(adminService.getTopicConfig(eq(ADDRESS_B), eq(TOPIC), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.NORMAL, 4, 4)));
+
+ SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+ service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+ .setTopic(resource(TOPIC)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ // MIXED degrades to UNSPECIFIED rather than pretending one broker is right
+ assertEquals(MessageType.MESSAGE_TYPE_UNSPECIFIED, observer.value.getTopicMessageType());
+ }
+
+ @Test
+ public void describeTopicStatusFailsWhenRouteMissingTest() {
+ SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+ service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+ .setTopic(resource("missingTopic")).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("topic route not found"));
+ }
+
+ // ------------------------------------------------------------------ 3/4. List & DescribeSubscription
+
+ @Test
+ public void listSubscriptionRejectsEmptyFiltersTest() {
+ SimpleObserver<ListSubscriptionResponse> observer = new SimpleObserver<>();
+ service.listSubscription(ListSubscriptionRequest.newBuilder().build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("at least one of topic or group"));
+ }
+
+ @Test
+ public void describeSubscriptionRejectsEmptyFiltersTest() {
+ SimpleObserver<DescribeSubscriptionResponse> observer = new SimpleObserver<>();
+ service.describeSubscription(DescribeSubscriptionRequest.newBuilder().build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("at least one of topic or group"));
+ }
+
+ @Test
+ public void listSubscriptionReturnsBrokerSideSubscriptionsTest() {
+ stubClusterInfo(ADDRESS_A);
+ ConsumerConnection connection = consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC);
+ // retry topics are bookkeeping, not user-visible subscriptions
+ connection.getSubscriptionTable().put(MixAll.getRetryTopic(GROUP),
+ subscriptionData(MixAll.getRetryTopic(GROUP), ExpressionType.TAG, "*"));
+ connection.getSubscriptionTable().put(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP),
+ subscriptionData(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP), ExpressionType.TAG, "*"));
+ when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(connection));
+
+ SimpleObserver<ListSubscriptionResponse> observer = new SimpleObserver<>();
+ service.listSubscription(ListSubscriptionRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getSubscriptionInfoCount());
+ SubscriptionInfo info = observer.value.getSubscriptionInfo(0);
+ assertEquals(GROUP, info.getGroup().getName());
+ assertEquals(TOPIC, info.getTopic().getName());
+ assertEquals(FilterType.TAG, info.getExpression().getType());
+ assertEquals("tagA", info.getExpression().getExpression());
+ assertTrue(info.getOnline());
+ assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING, info.getMessageModel());
+ }
+
+ @Test
+ public void listSubscriptionByTopicResolvesGroupsFromBrokersTest() {
+ stubClusterInfo(ADDRESS_A);
+ GroupList groupList = new GroupList();
+ groupList.setGroupList(new HashSet<>(Collections.singletonList(GROUP)));
+ when(adminService.queryTopicConsumeByWho(eq(ADDRESS_A), eq(TOPIC), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(groupList));
+ when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+
+ SimpleObserver<ListSubscriptionResponse> observer = new SimpleObserver<>();
+ service.listSubscription(ListSubscriptionRequest.newBuilder()
+ .setTopic(resource(TOPIC)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getSubscriptionInfoCount());
+ assertEquals(GROUP, observer.value.getSubscriptionInfo(0).getGroup().getName());
+ verify(adminService).queryTopicConsumeByWho(eq(ADDRESS_A), eq(TOPIC), anyLong());
+ }
+
+ @Test
+ public void describeSubscriptionReportsOneEntryPerClientTest() {
+ stubClusterInfo(ADDRESS_A);
+ when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+
+ SimpleObserver<DescribeSubscriptionResponse> observer = new SimpleObserver<>();
+ service.describeSubscription(DescribeSubscriptionRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getClientSubscriptionInfoCount());
+ assertEquals(CLIENT_ID, observer.value.getClientSubscriptionInfo(0).getClientInfo().getClientId());
+ SubscriptionInfo info = observer.value.getClientSubscriptionInfo(0).getSubscriptionInfo();
+ assertEquals(GROUP, info.getGroup().getName());
+ assertEquals(TOPIC, info.getTopic().getName());
+ assertEquals("tagA", info.getExpression().getExpression());
+ assertTrue(info.getOnline());
+ }
+
+ @Test
+ public void describeSubscriptionPrefersClientSettingsTest() {
+ stubClusterInfo(ADDRESS_A);
+ when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+ when(grpcClientSettingsManager.getRawClientSettings(CLIENT_ID))
+ .thenReturn(settings(ClientType.SIMPLE_CONSUMER, GROUP, TOPIC, "tagFromSettings"));
+
+ SimpleObserver<DescribeSubscriptionResponse> observer = new SimpleObserver<>();
+ service.describeSubscription(DescribeSubscriptionRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getClientSubscriptionInfoCount());
+ assertEquals("tagFromSettings",
+ observer.value.getClientSubscriptionInfo(0).getSubscriptionInfo().getExpression().getExpression());
+ }
+
+ // ------------------------------------------------------------------ 5. DeleteSubscription
+
+ @Test
+ public void deleteSubscriptionRejectsMissingTopicOrGroupTest() {
+ SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+ service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void deleteSubscriptionRemovesOnlyMatchingEntryTest() throws Exception {
+ stubRoute(true, ADDRESS_A);
+ SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+ config.setGroupName(GROUP);
+ Set<SimpleSubscriptionData> dataSet = new HashSet<>();
+ dataSet.add(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagA", 0L));
+ dataSet.add(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagB", 0L));
+ dataSet.add(new SimpleSubscriptionData("topicOther", ExpressionType.TAG, "*", 0L));
+ config.setSubscriptionDataSet(dataSet);
+ when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(config));
+ when(adminService.updateSubscriptionGroupConfig(eq(ADDRESS_A), any(SubscriptionGroupConfig.class), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+ service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setGroup(resource(GROUP))
+ .setExpression(FilterExpression.newBuilder().setType(FilterType.TAG).setExpression("tagA").build())
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+
+ ArgumentCaptor<SubscriptionGroupConfig> captor = ArgumentCaptor.forClass(SubscriptionGroupConfig.class);
+ verify(adminService).updateSubscriptionGroupConfig(eq(ADDRESS_A), captor.capture(), anyLong());
+ Set<SimpleSubscriptionData> remaining = captor.getValue().getSubscriptionDataSet();
+ assertEquals(2, remaining.size());
+ assertTrue(remaining.contains(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagB", 0L)));
+ assertTrue(remaining.contains(new SimpleSubscriptionData("topicOther", ExpressionType.TAG, "*", 0L)));
+ assertFalse(remaining.contains(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagA", 0L)));
+ // deleting one subscription must never delete the whole group
+ verify(adminService, never()).deleteSubscriptionGroup(anyString(), anyString(), anyBoolean(), anyLong());
+ }
+
+ @Test
+ public void deleteSubscriptionReturnsNotFoundWhenNothingMatchedTest() throws Exception {
+ stubRoute(true, ADDRESS_A);
+ SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+ config.setGroupName(GROUP);
+ config.setSubscriptionDataSet(new HashSet<>(Collections.singletonList(
+ new SimpleSubscriptionData("topicOther", ExpressionType.TAG, "*", 0L))));
+ when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(config));
+
+ SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+ service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setGroup(resource(GROUP))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("no subscription of group"));
+ verify(adminService, never()).updateSubscriptionGroupConfig(anyString(),
+ any(SubscriptionGroupConfig.class), anyLong());
+ }
+
+ @Test
+ public void deleteSubscriptionReturnsNotFoundWhenGroupUnknownTest() throws Exception {
+ stubRoute(true, ADDRESS_A);
+ when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+ service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setGroup(resource(GROUP))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void deleteSubscriptionPropagatesBrokerFailureTest() throws Exception {
+ stubRoute(true, ADDRESS_A);
+ when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(failedFuture(new RuntimeException("broker down")));
+
+ SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+ service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setGroup(resource(GROUP))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("broker down"));
+ }
+
+ // ------------------------------------------------------------------ 6. DescribeGroupAccumulation
+
+ @Test
+ public void describeGroupAccumulationRejectsMissingGroupTest() {
+ SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+ service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder().build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void describeGroupAccumulationSplitsInflightReadyTest() {
+ stubClusterInfo(ADDRESS_A);
+ // the service asks the broker for the whole group (blank topic) and filters locally
+ when(adminService.getConsumeStats(eq(ADDRESS_A), eq(GROUP), eq(""), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(consumeStats(TOPIC, 100L, 60L, 75L)));
+
+ SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+ service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .addTopics(resource(TOPIC))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(40L, observer.value.getAccumulation().getAccumulation());
+ assertEquals(15L, observer.value.getAccumulation().getInflightMessages());
+ assertEquals(25L, observer.value.getAccumulation().getReadyMessages());
+ assertTrue(observer.value.getTopicAccumulationMap().containsKey(TOPIC));
+ assertEquals(40L, observer.value.getTopicAccumulationMap().get(TOPIC).getAccumulation());
+ }
+
+ @Test
+ public void describeGroupAccumulationWholeGroupResolvesTopicsTest() {
+ stubClusterInfo(ADDRESS_A);
+ TopicList topicList = new TopicList();
+ topicList.setTopicList(new HashSet<>(Collections.singletonList(TOPIC)));
+ when(adminService.queryTopicsByConsumer(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(topicList));
+ when(adminService.getConsumeStats(eq(ADDRESS_A), eq(GROUP), eq(""), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(consumeStats(TOPIC, 100L, 60L, 60L)));
+
+ SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+ service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(40L, observer.value.getAccumulation().getAccumulation());
+ verify(adminService).queryTopicsByConsumer(eq(ADDRESS_A), eq(GROUP), anyLong());
+ }
+
+ // ------------------------------------------------------------------ 7. ListConsumerConnection
+
+ @Test
+ public void listConsumerConnectionRejectsMissingGroupTest() {
+ SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+ service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder().build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void listConsumerConnectionReturnsBrokerSideClientsTest() {
+ stubClusterInfo(ADDRESS_A);
+ when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(
+ consumerConnection(CLIENT_ID, "consumer-host@192.168.1.5:43210", TOPIC)));
+
+ SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+ service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getClientInfoCount());
+ assertEquals(CLIENT_ID, observer.value.getClientInfo(0).getClientId());
+ // egress_ip is the bare IP, hostname the client-reported name from "hostname@ip:port"
+ assertEquals("192.168.1.5", observer.value.getClientInfo(0).getEgressIp());
+ assertEquals("consumer-host", observer.value.getClientInfo(0).getHostname());
+ assertEquals("355", observer.value.getClientInfo(0).getVersion());
+ assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING, observer.value.getClientInfo(0).getMessageModel());
+ }
+
+ @Test
+ public void listConsumerConnectionOfflineGroupIsOkTest() {
+ stubClusterInfo(ADDRESS_A);
+ when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(failedFuture(new RuntimeException("consumer group not online")));
+
+ SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+ service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ // an offline group is a normal answer for a listing RPC, not a server error
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(0, observer.value.getClientInfoCount());
+ }
+
+ @Test
+ public void listConsumerConnectionTopicFilterHidesNonSubscribersTest() {
+ stubClusterInfo(ADDRESS_A);
+ when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+
+ SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+ service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setTopic(resource("topicOther"))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(0, observer.value.getClientInfoCount());
+ }
+
+ // ------------------------------------------------------------------ 8. ResetGroupOffset
+
+ @Test
+ public void resetGroupOffsetRejectsMissingTimestampTest() {
+ SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+ service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setTopic(resource(TOPIC))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ // an unset Timestamp used to mean "replay everything since 1970"
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("reset_timestamp"));
+ verify(adminService, never()).resetOffset(anyString(), anyString(), anyString(), anyLong(), anyBoolean(),
+ anyLong());
+ }
+
+ @Test
+ public void resetGroupOffsetRejectsZeroTimestampTest() {
+ SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+ service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setTopic(resource(TOPIC))
+ .setResetTimestamp(Timestamp.newBuilder().setSeconds(0L).build())
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ verify(adminService, never()).resetOffset(anyString(), anyString(), anyString(), anyLong(), anyBoolean(),
+ anyLong());
+ }
+
+ @Test
+ public void resetGroupOffsetReachesEveryBrokerTest() throws Exception {
+ stubRoute(true, ADDRESS_A, ADDRESS_B);
+ when(adminService.resetOffset(anyString(), eq(TOPIC), eq(GROUP), anyLong(), eq(true), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.emptyMap()));
+
+ SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+ service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setTopic(resource(TOPIC))
+ .setResetTimestamp(Timestamp.newBuilder().setSeconds(1000L).setNanos(500_000_000).build())
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ // seconds + nanos must be converted to one millisecond timestamp
+ verify(adminService).resetOffset(eq(ADDRESS_A), eq(TOPIC), eq(GROUP), eq(1000500L), eq(true), anyLong());
+ verify(adminService).resetOffset(eq(ADDRESS_B), eq(TOPIC), eq(GROUP), eq(1000500L), eq(true), anyLong());
+ }
+
+ @Test
+ public void resetGroupOffsetPropagatesBrokerFailureTest() throws Exception {
+ stubRoute(true, ADDRESS_A);
+ when(adminService.resetOffset(anyString(), eq(TOPIC), eq(GROUP), anyLong(), eq(true), anyLong()))
+ .thenReturn(failedFuture(new RuntimeException("reset rejected")));
+
+ SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+ service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setTopic(resource(TOPIC))
+ .setResetTimestamp(Timestamp.newBuilder().setSeconds(1000L).build())
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("reset rejected"));
+ }
+
+ // ------------------------------------------------------------------ 9. QueryMessage
+
+ @Test
+ public void queryMessageRejectsMissingTopicTest() {
+ SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder().setMessageKey("key").build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void queryMessageRejectsUnsupportedSearchKeyTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ SimpleObserver<ListMessageResponse> noKeyObserver = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder().setTopic(resource(TOPIC)).build(), noKeyObserver);
+ assertNotNull(noKeyObserver.value);
+ assertEquals(Code.BAD_REQUEST, noKeyObserver.value.getStatus().getCode());
+
+ SimpleObserver<ListMessageResponse> subscriptionObserver = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setSubscription("tagA")
+ .build(), subscriptionObserver);
+ assertNotNull(subscriptionObserver.value);
+ assertEquals(Code.BAD_REQUEST, subscriptionObserver.value.getStatus().getCode());
+ assertTrue(subscriptionObserver.value.getStatus().getMessage().contains("not supported"));
+ }
+
+ @Test
+ public void queryMessageByMessageIdUsesUniqueKeyTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("UNIQ-1"))));
+
+ SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setMessageId("UNIQ-1")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getMessagesCount());
+ assertEquals("UNIQ-1", observer.value.getMessages(0).getSystemProperties().getMessageId());
+ assertEquals("payload", observer.value.getMessages(0).getBody().toString(StandardCharsets.UTF_8));
+
+ // a v2 message_id is the client-generated unique key, so the lookup must use the
+ // unique-key index (uniqueKey=true), never the offset-decoding path
+ ArgumentCaptor<Boolean> uniqueKeyCaptor = ArgumentCaptor.forClass(Boolean.class);
+ verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("UNIQ-1"), anyInt(), anyLong(), anyLong(),
+ uniqueKeyCaptor.capture(), anyBoolean(), anyLong());
+ assertTrue(uniqueKeyCaptor.getValue());
+ }
+
+ @Test
+ public void queryMessageByKeyUsesKeyIndexTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("MSG-1"))));
+
+ SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setMessageKey("key-1")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getMessagesCount());
+
+ ArgumentCaptor<Boolean> uniqueKeyCaptor = ArgumentCaptor.forClass(Boolean.class);
+ verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("key-1"), anyInt(), anyLong(), anyLong(),
+ uniqueKeyCaptor.capture(), anyBoolean(), anyLong());
+ assertFalse(uniqueKeyCaptor.getValue());
+ }
+
+ @Test
+ public void queryMessageReturnsNotFoundWhenNoBrokerHasItTest() throws Exception {
+ stubRoute(false, ADDRESS_A, ADDRESS_B);
+ when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
+
+ SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setMessageId("UNIQ-MISSING")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.MESSAGE_NOT_FOUND, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("no message found"));
+ assertEquals(0, observer.value.getMessagesCount());
+ }
+
+ @Test
+ public void queryMessageSurvivesPartialBrokerFailureTest() throws Exception {
+ stubRoute(false, ADDRESS_A, ADDRESS_B);
+ when(adminService.queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("key-1"), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(failedFuture(new RuntimeException("broker-a down")));
+ when(adminService.queryMessage(eq(ADDRESS_B), eq(TOPIC), eq("key-1"), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("MSG-2"))));
+
+ SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setMessageKey("key-1")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ // a partially available cluster still returns the data it has
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getMessagesCount());
+ }
+
+ @Test
+ public void queryMessageRespectsMaxNumsTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Arrays.asList(messageExt("MSG-1"), messageExt("MSG-2"))));
+
+ SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+ service.queryMessage(ListMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setMessageKey("key-1")
+ .setMaxMessageNums(1)
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getMessagesCount());
+ verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("key-1"), eq(1), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong());
+ }
+
+ // ------------------------------------------------------------------ 10/13. client relay RPCs
+
+ @Test
+ public void printThreadStackTraceReturnsJstackTest() {
+ stubLocalClient();
+ ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+ runningInfo.setJstack("STACK-TRACE");
+ answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+ new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", runningInfo));
+
+ SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+ service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals("STACK-TRACE", observer.value.getThreadStackTrace());
+
+ ArgumentCaptor<ProxyRelayRequest> captor = ArgumentCaptor.forClass(ProxyRelayRequest.class);
+ verify(channel).writeAndFlush(captor.capture());
+ GetConsumerRunningInfoRequestHeader header =
+ (GetConsumerRunningInfoRequestHeader) captor.getValue().readCustomHeader();
+ assertTrue(header.isJstackEnable());
+ assertEquals(GROUP, header.getConsumerGroup());
+ assertEquals(CLIENT_ID, header.getClientId());
+ }
+
+ @Test
+ public void printThreadStackTraceEmptyJstackIsNotFoundTest() {
+ stubLocalClient();
+ answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+ new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", new ConsumerRunningInfo()));
+
+ SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+ service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("did not return a thread stack"));
+ }
+
+ @Test
+ public void printThreadStackTraceMissingClientIsNotFoundTest() {
+ SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+ service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+ .setClientId("missing-client")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("not connected"));
+ }
+
+ @Test
+ public void printThreadStackTraceForwardsToOwningProxyTest() {
+ when(forwarder.forwardIfRemote(eq(GROUP), eq(CLIENT_ID), any(), any())).thenReturn(true);
+
+ SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+ service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .build(), observer);
+
+ // ownership of the observer moved to the peer proxy: the local service must stay silent
+ assertNull(observer.value);
+ assertNull(observer.error);
+ assertFalse(observer.completed);
+ }
+
+ @Test
+ public void getConsumerRunningInfoRejectsMissingClientIdTest() {
+ SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+ service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void getConsumerRunningInfoReturnsFullInfoTest() {
+ stubLocalClient();
+ ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+ Properties properties = runningInfo.getProperties();
+ properties.setProperty(ConsumerRunningInfo.PROP_CLIENT_VERSION, "V5_0_0");
+ runningInfo.getSubscriptionSet().add(subscriptionData(TOPIC, ExpressionType.TAG, "tagA"));
+ answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+ new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", runningInfo));
+
+ SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+ service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals("V5_0_0", observer.value.getConsumerRunningInfo()
+ .getPropertiesMap().get(ConsumerRunningInfo.PROP_CLIENT_VERSION));
+ assertTrue(observer.value.getConsumerRunningInfo().getSubscriptionsMap().containsKey(TOPIC));
+
+ ArgumentCaptor<ProxyRelayRequest> captor = ArgumentCaptor.forClass(ProxyRelayRequest.class);
+ verify(channel).writeAndFlush(captor.capture());
+ GetConsumerRunningInfoRequestHeader header =
+ (GetConsumerRunningInfoRequestHeader) captor.getValue().readCustomHeader();
+ assertFalse(header.isJstackEnable());
+ }
+
+ @Test
+ public void getConsumerRunningInfoFallsBackToSubscriptionsForGrpcClientTest() {
+ stubLocalClient();
+ // the GrpcClientChannel jstack guard completes the relay future with this result for a
+ // gRPC v2 client that cannot answer a running-info request without a thread dump
+ answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+ new ProxyRelayResult<>(ResponseCode.REQUEST_CODE_NOT_SUPPORTED,
+ "gRPC v2 protocol cannot report consumer running info without jstack, "
+ + "retry with jstackEnable=true", null));
+ when(grpcClientSettingsManager.getRawClientSettings(CLIENT_ID))
+ .thenReturn(settings(ClientType.SIMPLE_CONSUMER, GROUP, TOPIC, "tagA"));
+
+ SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+ service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("only subscriptions are available"));
+ assertEquals("tagA", observer.value.getConsumerRunningInfo()
+ .getSubscriptionsMap().get(TOPIC).getExpression());
+ }
+
+ @Test
+ public void getConsumerRunningInfoErrorsWithoutSettingsOrChannelTest() {
+ SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+ service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+ .setClientId("missing-client")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("not connected"));
+ }
+
+ // ------------------------------------------------------------------ 11. VerifyMessage
+
+ @Test
+ public void verifyMessageRejectsMissingFieldsTest() {
+ SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+ service.verifyMessage(VerifyMessageRequest.newBuilder()
+ .setClientId(CLIENT_ID)
+ .setTopic(resource(TOPIC))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void verifyMessageSuccessTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ stubLocalClient();
+ when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("UNIQ-9"))));
+ ConsumeMessageDirectlyResult directlyResult = new ConsumeMessageDirectlyResult();
+ directlyResult.setConsumeResult(CMResult.CR_SUCCESS);
+ answerRelayWith(RequestCode.CONSUME_MESSAGE_DIRECTLY,
+ new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", directlyResult));
+
+ SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+ service.verifyMessage(VerifyMessageRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .setTopic(resource(TOPIC))
+ .setMessageId("UNIQ-9")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+
+ // the real message is fetched through the unique-key index and relayed to the client
+ verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("UNIQ-9"), eq(1), eq(0L), eq(Long.MAX_VALUE),
+ eq(true), eq(false), anyLong());
+ ArgumentCaptor<ProxyRelayRequest> captor = ArgumentCaptor.forClass(ProxyRelayRequest.class);
+ verify(channel).writeAndFlush(captor.capture());
+ ConsumeMessageDirectlyResultRequestHeader header =
+ (ConsumeMessageDirectlyResultRequestHeader) captor.getValue().readCustomHeader();
+ assertEquals(GROUP, header.getConsumerGroup());
+ assertEquals(CLIENT_ID, header.getClientId());
+ assertEquals(TOPIC, header.getTopic());
+ assertEquals("UNIQ-9", header.getMsgId());
+ assertNotNull(captor.getValue().getBody());
+ }
+
+ @Test
+ public void verifyMessageReportsCorruptedOnFailureTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ stubLocalClient();
+ when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("UNIQ-9"))));
+ ConsumeMessageDirectlyResult directlyResult = new ConsumeMessageDirectlyResult();
+ directlyResult.setConsumeResult(CMResult.CR_THROW_EXCEPTION);
+ directlyResult.setRemark("consume crashed");
+ answerRelayWith(RequestCode.CONSUME_MESSAGE_DIRECTLY,
+ new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", directlyResult));
+
+ SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+ service.verifyMessage(VerifyMessageRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .setTopic(resource(TOPIC))
+ .setMessageId("UNIQ-9")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.MESSAGE_CORRUPTED, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("failed to consume message UNIQ-9"));
+ assertTrue(observer.value.getStatus().getMessage().contains("consume crashed"));
+ }
+
+ @Test
+ public void verifyMessageFailsWhenMessageNotFoundTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+ anyBoolean(), anyBoolean(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
+
+ SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+ service.verifyMessage(VerifyMessageRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .setClientId(CLIENT_ID)
+ .setTopic(resource(TOPIC))
+ .setMessageId("UNIQ-MISSING")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ // a missing message is a normal answer, so it must not be reported as a proxy fault;
+ // same grading queryMessage uses
+ assertEquals(Code.MESSAGE_NOT_FOUND, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("not found"));
+ }
+
+ // ------------------------------------------------------------------ 12. AdminSendMessage
+
+ @Test
+ public void adminSendMessageReturnsIdTest() {
+ SendResult sendResult = new SendResult();
+ sendResult.setMsgId("SENT-1");
+ when(messagingProcessor.sendMessage(any(), any(), anyString(), anyInt(), anyList(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(sendResult)));
+
+ SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+ service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setBody(ByteString.copyFromUtf8("hello"))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals("SENT-1", observer.value.getMessageId());
+ }
+
+ @Test
+ public void adminSendMessageCarriesTagsKeysAndPropertiesTest() {
+ SendResult sendResult = new SendResult();
+ sendResult.setMsgId("SENT-2");
+ when(messagingProcessor.sendMessage(any(), any(), anyString(), anyInt(), anyList(), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(sendResult)));
+
+ SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+ service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setBody(ByteString.copyFromUtf8("hello"))
+ .setTag("tagA")
+ .setKey("key1")
+ .putUserProperties("traceId", "abc")
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals("SENT-2", observer.value.getMessageId());
+
+ @SuppressWarnings("rawtypes")
+ ArgumentCaptor<List> messagesCaptor = ArgumentCaptor.forClass(List.class);
+ verify(messagingProcessor).sendMessage(any(), any(), anyString(), anyInt(), messagesCaptor.capture(),
+ anyLong());
+ Message message = (Message) messagesCaptor.getValue().get(0);
+ assertEquals(TOPIC, message.getTopic());
+ assertEquals("tagA", message.getTags());
+ assertEquals("key1", message.getKeys());
+ assertEquals("abc", message.getUserProperty("traceId"));
+ }
+
+ @Test
+ public void adminSendMessageRejectsPastDeliveryTimestampTest() {
+ SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+ service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setBody(ByteString.copyFromUtf8("hello"))
+ .setSystemProperties(SystemProperties.newBuilder()
+ .setDeliveryTimestamp(Timestamp.newBuilder()
+ .setSeconds(System.currentTimeMillis() / 1000L - 100L).build())
+ .build())
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.ILLEGAL_DELIVERY_TIME, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void adminSendMessageFailsGracefullyWhenSendFailsTest() {
+ when(messagingProcessor.sendMessage(any(), any(), anyString(), anyInt(), anyList(), anyLong()))
+ .thenReturn(failedFuture(new RuntimeException("send failed")));
+
+ SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+ service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+ .setTopic(resource(TOPIC))
+ .setBody(ByteString.copyFromUtf8("hello"))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("send failed"));
+ }
+
+ // ------------------------------------------------------------------ 14. GetTopicRoute
+
+ @Test
+ public void getTopicRouteReturnsJsonTest() {
+ TopicRouteData routeData = new TopicRouteData();
+ routeData.setOrderTopicConf("orderConf");
+ when(adminService.getTopicRouteData(TOPIC)).thenReturn(CompletableFuture.completedFuture(routeData));
+
+ SimpleObserver<GetTopicRouteResponse> observer = new SimpleObserver<>();
+ service.getTopicRoute(GetTopicRouteRequest.newBuilder().setTopic(resource(TOPIC)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(JSON.toJSONString(routeData), observer.value.getTopicRouteData());
+ }
+
+ @Test
+ public void getTopicRouteFailsWhenAdminServiceFailsTest() {
+ when(adminService.getTopicRouteData(TOPIC))
+ .thenReturn(failedFuture(new RuntimeException("namesrv unreachable")));
+
+ SimpleObserver<GetTopicRouteResponse> observer = new SimpleObserver<>();
+ service.getTopicRoute(GetTopicRouteRequest.newBuilder().setTopic(resource(TOPIC)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("namesrv unreachable"));
+ }
+
+ @Test
+ public void getTopicRouteRejectsMissingTopicTest() {
+ SimpleObserver<GetTopicRouteResponse> observer = new SimpleObserver<>();
+ service.getTopicRoute(GetTopicRouteRequest.newBuilder().build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ // ------------------------------------------------------------------ 15. QueryTimeSpan
+
+ @Test
+ public void queryTimeSpanRejectsMissingGroupTest() {
+ SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+ service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+ .addTopics(resource(TOPIC)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void queryTimeSpanRejectsMissingTopicsTest() {
+ SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+ service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+ .setGroup(resource(GROUP)).build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+ }
+
+ @Test
+ public void queryTimeSpanReturnsBrokerComputedSpansTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ QueueTimeSpan span = new QueueTimeSpan();
+ span.setMessageQueue(new MessageQueue(TOPIC, "broker-0", 0));
+ span.setMinTimeStamp(100L);
+ span.setMaxTimeStamp(200L);
+ span.setConsumeTimeStamp(150L);
+ span.setDelayTime(42L);
+ when(adminService.queryConsumeTimeSpan(eq(ADDRESS_A), eq(TOPIC), eq(GROUP), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(span)));
+
+ SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+ service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .addTopics(resource(TOPIC))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1, observer.value.getQueueTimeSpanListCount());
+ QueryTimeSpanResponse.QueueTimeSpan v2Span = observer.value.getQueueTimeSpanList(0);
+ assertEquals("broker-0", v2Span.getMessageQueue().getBroker().getName());
+ assertEquals(100L, v2Span.getMinTimestamp());
+ assertEquals(200L, v2Span.getMaxTimestamp());
+ assertEquals(150L, v2Span.getConsumeTimestamp());
+ assertEquals(42L, v2Span.getDelayTimeMs());
+ }
+
+ @Test
+ public void queryTimeSpanPropagatesFailureWhenAllBrokersFailTest() throws Exception {
+ stubRoute(false, ADDRESS_A);
+ when(adminService.queryConsumeTimeSpan(eq(ADDRESS_A), eq(TOPIC), eq(GROUP), anyLong()))
+ .thenReturn(failedFuture(new RuntimeException("span query failed")));
+
+ SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+ service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .addTopics(resource(TOPIC))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+ assertTrue(observer.value.getStatus().getMessage().contains("span query failed"));
+ }
+
+ // ------------------------------------------------------------------ 16. GetProxyRuntimeStats
+
+ @Test
+ public void getProxyRuntimeStatsCountsLitePushConsumerAsConsumerTest() {
+ GrpcClientChannel consumerChannel = mock(GrpcClientChannel.class);
+ when(consumerChannel.getClientId()).thenReturn(CLIENT_ID);
+ GrpcClientChannel producerChannel = mock(GrpcClientChannel.class);
+ when(producerChannel.getClientId()).thenReturn("producer-1");
+ GrpcClientChannel litePushChannel = mock(GrpcClientChannel.class);
+ when(litePushChannel.getClientId()).thenReturn("lite-push-1");
+ when(grpcChannelManager.getClientChannels())
+ .thenReturn(Arrays.asList(consumerChannel, producerChannel, litePushChannel));
+ when(grpcClientSettingsManager.getRawClientSettings(CLIENT_ID))
+ .thenReturn(settings(ClientType.SIMPLE_CONSUMER, GROUP, TOPIC, "*"));
+ when(grpcClientSettingsManager.getRawClientSettings("producer-1"))
+ .thenReturn(settings(ClientType.PRODUCER, GROUP, TOPIC, "*"));
+ when(grpcClientSettingsManager.getRawClientSettings("lite-push-1"))
+ .thenReturn(settings(ClientType.LITE_PUSH_CONSUMER, GROUP, TOPIC, "*"));
+
+ SimpleObserver<GetProxyRuntimeStatsResponse> observer = new SimpleObserver<>();
+ service.getProxyRuntimeStats(GetProxyRuntimeStatsRequest.newBuilder().build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ // LITE_PUSH_CONSUMER is a consumer, not a producer
+ assertEquals(2L, observer.value.getConsumers());
+ assertEquals(1L, observer.value.getProducers());
+ assertEquals(3L, observer.value.getConnections());
+ // the version comes from MQVersion, not from a hardcoded string
+ assertEquals(MQVersion.getVersionDesc(MQVersion.CURRENT_VERSION), observer.value.getVersion());
+ assertEquals(ConfigurationManager.getProxyConfig().getProxyName(), observer.value.getProxyName());
+ }
+
+ @Test
+ public void getProxyRuntimeStatsSkipsChannelsWithoutSettingsTest() {
+ GrpcClientChannel unknownChannel = mock(GrpcClientChannel.class);
+ when(unknownChannel.getClientId()).thenReturn("unknown");
+ when(grpcChannelManager.getClientChannels()).thenReturn(Collections.singletonList(unknownChannel));
+
+ SimpleObserver<GetProxyRuntimeStatsResponse> observer = new SimpleObserver<>();
+ service.getProxyRuntimeStats(GetProxyRuntimeStatsRequest.newBuilder().build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ assertEquals(1L, observer.value.getConnections());
+ assertEquals(0L, observer.value.getProducers());
+ assertEquals(0L, observer.value.getConsumers());
+ }
+
+ // ------------------------------------------------------------------ cross-RPC
+
+ @Test
+ public void describeGroupAccumulationKeepsRetryBacklogInTotalTest() {
+ stubClusterInfo(ADDRESS_A);
+ ConsumeStats stats = consumeStats(TOPIC, 100L, 60L, 60L);
+ OffsetWrapper retryWrapper = new OffsetWrapper();
+ retryWrapper.setBrokerOffset(30L);
+ retryWrapper.setConsumerOffset(10L);
+ retryWrapper.setPullOffset(10L);
+ stats.getOffsetTable().put(new MessageQueue(MixAll.getRetryTopic(GROUP), "broker-0", 0), retryWrapper);
+ when(adminService.getConsumeStats(eq(ADDRESS_A), eq(GROUP), eq(""), anyLong()))
+ .thenReturn(CompletableFuture.completedFuture(stats));
+
+ SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+ service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder()
+ .setGroup(resource(GROUP))
+ .addTopics(resource(TOPIC))
+ .build(), observer);
+
+ assertNotNull(observer.value);
+ assertEquals(Code.OK, observer.value.getStatus().getCode());
+ // 40 from the topic + 20 from the group's retry topic
+ assertEquals(60L, observer.value.getAccumulation().getAccumulation());
+ assertEquals(40L, observer.value.getTopicAccumulationMap().get(TOPIC).getAccumulation());
+ assertFalse(observer.value.getTopicAccumulationMap().containsKey(MixAll.getRetryTopic(GROUP)));
+ }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java
index 1bdbdd9bef..4b84366f24 100644
--- a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java
@@ -20,6 +20,10 @@ package org.apache.rocketmq.proxy.grpc.v2.channel;
import apache.rocketmq.v2.Publishing;
import apache.rocketmq.v2.Resource;
import apache.rocketmq.v2.Settings;
+import apache.rocketmq.v2.TelemetryCommand;
+import io.grpc.stub.StreamObserver;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.rocketmq.proxy.common.ProxyContext;
import org.apache.rocketmq.proxy.config.InitConfigTest;
@@ -27,17 +31,30 @@ import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
import org.apache.rocketmq.proxy.processor.channel.ChannelProtocolType;
import org.apache.rocketmq.proxy.processor.channel.RemoteChannel;
import org.apache.rocketmq.proxy.remoting.channel.RemotingChannel;
+import org.apache.rocketmq.proxy.service.relay.ClusterProxyRelayService;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayRequest;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
import org.apache.rocketmq.proxy.service.relay.ProxyRelayService;
+import org.apache.rocketmq.proxy.service.transaction.TransactionService;
+import org.apache.rocketmq.remoting.protocol.RequestCode;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
@@ -79,4 +96,60 @@ public class GrpcClientChannelTest extends InitConfigTest {
assertEquals(clientSettings, GrpcClientChannel.parseChannelExtendAttribute(this.grpcClientChannel));
assertNull(GrpcClientChannel.parseChannelExtendAttribute(mock(RemotingChannel.class)));
}
+
+ @Test
+ public void getConsumerRunningInfoWithoutJstackCompletesWithNotSupportedTest() throws Exception {
+ GrpcClientChannel channel = new GrpcClientChannel(
+ new ClusterProxyRelayService(mock(TransactionService.class)), grpcClientSettingsManager,
+ grpcChannelManager,
+ ProxyContext.create().setRemoteAddress("10.152.39.53:9768").setLocalAddress("11.193.0.1:1210"),
+ clientId);
+
+ GetConsumerRunningInfoRequestHeader header = new GetConsumerRunningInfoRequestHeader();
+ header.setConsumerGroup("group");
+ header.setClientId(clientId);
+ header.setJstackEnable(false);
+ CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> callerFuture = new CompletableFuture<>();
+
+ channel.writeAndFlush(ProxyRelayRequest.createRequestCommand(
+ RequestCode.GET_CONSUMER_RUNNING_INFO, header, callerFuture));
+
+ // the gRPC v2 telemetry protocol has no running-info reply without a thread dump; the guard
+ // must complete the caller immediately instead of letting it hang until the relay timeout
+ assertTrue(callerFuture.isDone());
+ ProxyRelayResult<ConsumerRunningInfo> result = callerFuture.get(1, TimeUnit.SECONDS);
+ assertEquals(ResponseCode.REQUEST_CODE_NOT_SUPPORTED, result.getCode());
+ assertTrue(result.getRemark().contains("jstackEnable=true"));
+ assertNull(result.getResult());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void getConsumerRunningInfoWithJstackWritesTelemetryCommandTest() {
+ GrpcClientChannel channel = new GrpcClientChannel(
+ new ClusterProxyRelayService(mock(TransactionService.class)), grpcClientSettingsManager,
+ grpcChannelManager,
+ ProxyContext.create().setRemoteAddress("10.152.39.53:9768").setLocalAddress("11.193.0.1:1210"),
+ clientId);
+ StreamObserver<TelemetryCommand> observer = mock(StreamObserver.class);
+ channel.setClientObserver(observer);
+ when(grpcChannelManager.addResponseFuture(any())).thenReturn("nonce-1");
+
+ GetConsumerRunningInfoRequestHeader header = new GetConsumerRunningInfoRequestHeader();
+ header.setConsumerGroup("group");
+ header.setClientId(clientId);
+ header.setJstackEnable(true);
+ CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> callerFuture = new CompletableFuture<>();
+
+ channel.writeAndFlush(ProxyRelayRequest.createRequestCommand(
+ RequestCode.GET_CONSUMER_RUNNING_INFO, header, callerFuture));
+
+ ArgumentCaptor<TelemetryCommand> captor = ArgumentCaptor.forClass(TelemetryCommand.class);
+ verify(observer).onNext(captor.capture());
+ TelemetryCommand command = captor.getValue();
+ assertTrue(command.hasPrintThreadStackTraceCommand());
+ assertEquals("nonce-1", command.getPrintThreadStackTraceCommand().getNonce());
+ // the answer arrives through the telemetry stream, so the caller future stays pending here
+ assertFalse(callerFuture.isDone());
+ }
}
\ No newline at end of file
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java
index cdfc7f7fc2..b8136e948d 100644
--- a/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java
@@ -14,19 +14,42 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
package org.apache.rocketmq.proxy.service.admin;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Set;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.apache.rocketmq.client.impl.admin.MqClientAdminImpl;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.TopicConfig;
-import org.apache.rocketmq.remoting.protocol.ResponseCode;
-import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.DeleteSubscriptionGroupRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumeStatsRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerConnectionListRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetTopicStatsInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryConsumeTimeSpanRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryMessageRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicConsumeByWhoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicsByConsumerRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ResetOffsetRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ViewMessageRequestHeader;
import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
+import org.apache.rocketmq.remoting.protocol.statictopic.TopicConfigAndQueueMapping;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -35,69 +58,320 @@ import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doNothing;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class DefaultAdminServiceTest {
+
@Mock
private MQClientAPIFactory mqClientAPIFactory;
@Mock
private MQClientAPIExt mqClientAPIExt;
+ @Mock
+ private MqClientAdminImpl mqClientAdmin;
- private DefaultAdminService defaultAdminService;
+ private DefaultAdminService adminService;
+
+ private static final String ADDR = "127.0.0.1:10911";
+ private static final String TOPIC = "topicA";
+ private static final String GROUP = "groupA";
+ private static final long TIMEOUT = 3000L;
+ private static final long GET_TIMEOUT_SECONDS = 5L;
@Before
- public void before() {
+ public void setUp() {
when(mqClientAPIFactory.getClient()).thenReturn(mqClientAPIExt);
- defaultAdminService = new DefaultAdminService(mqClientAPIFactory);
- }
-
- @Test
- public void testCreateTopic() throws Exception {
- when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq("createTopic"), anyLong()))
- .thenThrow(new MQClientException(ResponseCode.TOPIC_NOT_EXIST, ""))
- .thenReturn(createTopicRouteData(1));
- when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq("sampleTopic"), anyLong()))
- .thenReturn(createTopicRouteData(2));
-
- ArgumentCaptor<String> addrArgumentCaptor = ArgumentCaptor.forClass(String.class);
- ArgumentCaptor<TopicConfig> topicConfigArgumentCaptor = ArgumentCaptor.forClass(TopicConfig.class);
- doNothing().when(mqClientAPIExt).createTopic(addrArgumentCaptor.capture(), anyString(), topicConfigArgumentCaptor.capture(), anyLong());
-
- assertTrue(defaultAdminService.createTopicOnTopicBrokerIfNotExist(
- "createTopic",
- "sampleTopic",
- 7,
- 8,
- true,
- 1
- ));
-
- assertEquals(2, addrArgumentCaptor.getAllValues().size());
- Set<String> createAddr = new HashSet<>(addrArgumentCaptor.getAllValues());
- assertTrue(createAddr.contains("127.0.0.1:10911"));
- assertTrue(createAddr.contains("127.0.0.2:10911"));
- assertEquals("createTopic", topicConfigArgumentCaptor.getValue().getTopicName());
- assertEquals(7, topicConfigArgumentCaptor.getValue().getWriteQueueNums());
- assertEquals(8, topicConfigArgumentCaptor.getValue().getReadQueueNums());
- }
-
- private TopicRouteData createTopicRouteData(int brokerNum) {
- TopicRouteData topicRouteData = new TopicRouteData();
- for (int i = 0; i < brokerNum; i++) {
- BrokerData brokerData = new BrokerData();
- HashMap<Long, String> addrMap = new HashMap<>();
- addrMap.put(0L, "127.0.0." + (i + 1) + ":10911");
- brokerData.setBrokerAddrs(addrMap);
- brokerData.setBrokerName("broker-" + i);
- brokerData.setCluster("cluster");
- topicRouteData.getBrokerDatas().add(brokerData);
+ when(mqClientAPIExt.getMqClientAdmin()).thenReturn(mqClientAdmin);
+ adminService = new DefaultAdminService(mqClientAPIFactory);
+ }
+
+ @After
+ public void tearDown() {
+ adminService.shutdown();
+ }
+
+ private static <T> T get(CompletableFuture<T> future) throws Exception {
+ return future.get(GET_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void getBrokerClusterInfoQueriesNameserverTest() throws Exception {
+ ClusterInfo clusterInfo = new ClusterInfo();
+ when(mqClientAdmin.getBrokerClusterInfo(isNull(), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(clusterInfo));
+
+ assertSame(clusterInfo, get(adminService.getBrokerClusterInfo(TIMEOUT)));
+ }
+
+ @Test
+ public void getTopicRouteDataDelegatesToNameserverTest() throws Exception {
+ TopicRouteData routeData = new TopicRouteData();
+ routeData.setOrderTopicConf("orderConf");
+ when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq(TOPIC), anyLong())).thenReturn(routeData);
+
+ TopicRouteData result = get(adminService.getTopicRouteData(TOPIC));
+
+ assertNotNull(result);
+ assertEquals("orderConf", result.getOrderTopicConf());
+ }
+
+ @Test
+ public void getTopicRouteDataWrapsFailuresTest() throws Exception {
+ when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq(TOPIC), anyLong()))
+ .thenThrow(new MQClientException("boom", null));
+
+ try {
+ get(adminService.getTopicRouteData(TOPIC));
+ fail("expected getTopicRouteData to fail");
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ while (cause != null && !(cause instanceof MQClientException)) {
+ cause = cause.getCause();
+ }
+ assertNotNull(cause);
+ assertTrue(cause.getMessage().contains("boom"));
}
- return topicRouteData;
}
-}
\ No newline at end of file
+
+ @Test
+ public void getTopicConfigRunsBlockingCallOnExecutorTest() throws Exception {
+ TopicConfigAndQueueMapping topicConfig = new TopicConfigAndQueueMapping();
+ topicConfig.setTopicName(TOPIC);
+ topicConfig.setReadQueueNums(8);
+ when(mqClientAPIExt.getTopicConfig(ADDR, TOPIC, TIMEOUT)).thenReturn(topicConfig);
+
+ TopicConfig result = get(adminService.getTopicConfig(ADDR, TOPIC, TIMEOUT));
+
+ assertSame(topicConfig, result);
+ assertEquals(8, result.getReadQueueNums());
+ }
+
+ @Test
+ public void getTopicStatsBuildsHeaderTest() throws Exception {
+ TopicStatsTable statsTable = new TopicStatsTable();
+ when(mqClientAdmin.getTopicStatsInfo(eq(ADDR), any(GetTopicStatsInfoRequestHeader.class), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(statsTable));
+
+ assertSame(statsTable, get(adminService.getTopicStats(ADDR, TOPIC, TIMEOUT)));
+
+ ArgumentCaptor<GetTopicStatsInfoRequestHeader> captor =
+ ArgumentCaptor.forClass(GetTopicStatsInfoRequestHeader.class);
+ verify(mqClientAdmin).getTopicStatsInfo(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(TOPIC, captor.getValue().getTopic());
+ }
+
+ @Test
+ public void getConsumeStatsBuildsHeaderTest() throws Exception {
+ ConsumeStats consumeStats = new ConsumeStats();
+ when(mqClientAdmin.getConsumeStats(eq(ADDR), any(GetConsumeStatsRequestHeader.class), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(consumeStats));
+
+ assertSame(consumeStats, get(adminService.getConsumeStats(ADDR, GROUP, TOPIC, TIMEOUT)));
+ // a blank topic means "every topic of the group" and must reach the broker as ""
+ assertSame(consumeStats, get(adminService.getConsumeStats(ADDR, GROUP, null, TIMEOUT)));
+
+ ArgumentCaptor<GetConsumeStatsRequestHeader> captor =
+ ArgumentCaptor.forClass(GetConsumeStatsRequestHeader.class);
+ verify(mqClientAdmin, times(2))
+ .getConsumeStats(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ List<GetConsumeStatsRequestHeader> headers = captor.getAllValues();
+ assertEquals(GROUP, headers.get(0).getConsumerGroup());
+ assertEquals(TOPIC, headers.get(0).getTopic());
+ assertEquals(GROUP, headers.get(1).getConsumerGroup());
+ assertEquals("", headers.get(1).getTopic());
+ }
+
+ @Test
+ public void queryConsumeTimeSpanBuildsHeaderTest() throws Exception {
+ List<QueueTimeSpan> spans = Collections.singletonList(new QueueTimeSpan());
+ when(mqClientAdmin.queryConsumeTimeSpan(eq(ADDR), any(QueryConsumeTimeSpanRequestHeader.class), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(spans));
+
+ assertSame(spans, get(adminService.queryConsumeTimeSpan(ADDR, TOPIC, GROUP, TIMEOUT)));
+
+ ArgumentCaptor<QueryConsumeTimeSpanRequestHeader> captor =
+ ArgumentCaptor.forClass(QueryConsumeTimeSpanRequestHeader.class);
+ verify(mqClientAdmin).queryConsumeTimeSpan(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(TOPIC, captor.getValue().getTopic());
+ assertEquals(GROUP, captor.getValue().getGroup());
+ }
+
+ @Test
+ public void resetOffsetBuildsHeaderTest() throws Exception {
+ Map<MessageQueue, Long> offsets = Collections.singletonMap(new MessageQueue(TOPIC, "broker-a", 0), 42L);
+ when(mqClientAdmin.invokeBrokerToResetOffset(eq(ADDR), any(ResetOffsetRequestHeader.class), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(offsets));
+
+ Map<MessageQueue, Long> result =
+ get(adminService.resetOffset(ADDR, TOPIC, GROUP, 1234L, true, TIMEOUT));
+
+ assertEquals(42L, result.get(new MessageQueue(TOPIC, "broker-a", 0)).longValue());
+ ArgumentCaptor<ResetOffsetRequestHeader> captor = ArgumentCaptor.forClass(ResetOffsetRequestHeader.class);
+ verify(mqClientAdmin).invokeBrokerToResetOffset(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(TOPIC, captor.getValue().getTopic());
+ assertEquals(GROUP, captor.getValue().getGroup());
+ assertEquals(1234L, captor.getValue().getTimestamp());
+ assertTrue(captor.getValue().isForce());
+ }
+
+ @Test
+ public void getConsumerConnectionListBuildsHeaderTest() throws Exception {
+ ConsumerConnection connection = new ConsumerConnection();
+ when(mqClientAdmin.getConsumerConnectionList(eq(ADDR),
+ any(GetConsumerConnectionListRequestHeader.class), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(connection));
+
+ assertSame(connection, get(adminService.getConsumerConnectionList(ADDR, GROUP, TIMEOUT)));
+
+ ArgumentCaptor<GetConsumerConnectionListRequestHeader> captor =
+ ArgumentCaptor.forClass(GetConsumerConnectionListRequestHeader.class);
+ verify(mqClientAdmin).getConsumerConnectionList(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(GROUP, captor.getValue().getConsumerGroup());
+ }
+
+ @Test
+ public void queryTopicConsumeByWhoBuildsHeaderTest() throws Exception {
+ GroupList groupList = new GroupList();
+ when(mqClientAdmin.queryTopicConsumeByWho(eq(ADDR), any(QueryTopicConsumeByWhoRequestHeader.class),
+ eq(TIMEOUT))).thenReturn(CompletableFuture.completedFuture(groupList));
+
+ assertSame(groupList, get(adminService.queryTopicConsumeByWho(ADDR, TOPIC, TIMEOUT)));
+
+ ArgumentCaptor<QueryTopicConsumeByWhoRequestHeader> captor =
+ ArgumentCaptor.forClass(QueryTopicConsumeByWhoRequestHeader.class);
+ verify(mqClientAdmin).queryTopicConsumeByWho(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(TOPIC, captor.getValue().getTopic());
+ }
+
+ @Test
+ public void queryTopicsByConsumerBuildsHeaderTest() throws Exception {
+ TopicList topicList = new TopicList();
+ when(mqClientAdmin.queryTopicsByConsumer(eq(ADDR), any(QueryTopicsByConsumerRequestHeader.class),
+ eq(TIMEOUT))).thenReturn(CompletableFuture.completedFuture(topicList));
+
+ assertSame(topicList, get(adminService.queryTopicsByConsumer(ADDR, GROUP, TIMEOUT)));
+
+ ArgumentCaptor<QueryTopicsByConsumerRequestHeader> captor =
+ ArgumentCaptor.forClass(QueryTopicsByConsumerRequestHeader.class);
+ verify(mqClientAdmin).queryTopicsByConsumer(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(GROUP, captor.getValue().getGroup());
+ }
+
+ @Test
+ public void getSubscriptionGroupConfigRunsBlockingCallOnExecutorTest() throws Exception {
+ SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+ config.setGroupName(GROUP);
+ when(mqClientAPIExt.getSubscriptionGroupConfig(ADDR, GROUP, TIMEOUT)).thenReturn(config);
+
+ SubscriptionGroupConfig result = get(adminService.getSubscriptionGroupConfig(ADDR, GROUP, TIMEOUT));
+
+ assertSame(config, result);
+ }
+
+ @Test
+ public void updateSubscriptionGroupConfigDelegatesTest() throws Exception {
+ SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+ config.setGroupName(GROUP);
+ when(mqClientAdmin.updateOrCreateSubscriptionGroup(ADDR, config, TIMEOUT))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ get(adminService.updateSubscriptionGroupConfig(ADDR, config, TIMEOUT));
+
+ verify(mqClientAdmin).updateOrCreateSubscriptionGroup(ADDR, config, TIMEOUT);
+ }
+
+ @Test
+ public void deleteSubscriptionGroupBuildsHeaderTest() throws Exception {
+ when(mqClientAdmin.deleteSubscriptionGroup(eq(ADDR), any(DeleteSubscriptionGroupRequestHeader.class),
+ eq(TIMEOUT))).thenReturn(CompletableFuture.completedFuture(null));
+
+ get(adminService.deleteSubscriptionGroup(ADDR, GROUP, true, TIMEOUT));
+
+ ArgumentCaptor<DeleteSubscriptionGroupRequestHeader> captor =
+ ArgumentCaptor.forClass(DeleteSubscriptionGroupRequestHeader.class);
+ verify(mqClientAdmin).deleteSubscriptionGroup(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(GROUP, captor.getValue().getGroupName());
+ assertTrue(captor.getValue().isCleanOffset());
+ }
+
+ @Test
+ public void queryMessagePassesUniqueKeyAndDecompressFlagsTest() throws Exception {
+ List<MessageExt> messages = Collections.singletonList(new MessageExt());
+ when(mqClientAdmin.queryMessage(eq(ADDR), anyBoolean(), anyBoolean(),
+ any(QueryMessageRequestHeader.class), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(messages));
+
+ // uniqueKey=true selects the client-generated unique-key index; header fields carried through
+ assertSame(messages,
+ get(adminService.queryMessage(ADDR, TOPIC, "UNIQ-1", 10, 100L, 200L, true, true, TIMEOUT)));
+ ArgumentCaptor<QueryMessageRequestHeader> captor = ArgumentCaptor.forClass(QueryMessageRequestHeader.class);
+ verify(mqClientAdmin).queryMessage(eq(ADDR), eq(true), eq(true), captor.capture(), eq(TIMEOUT));
+ QueryMessageRequestHeader header = captor.getValue();
+ assertEquals(TOPIC, header.getTopic());
+ assertEquals("UNIQ-1", header.getKey());
+ assertEquals(10, header.getMaxNum().intValue());
+ assertEquals(100L, header.getBeginTimestamp().longValue());
+ assertEquals(200L, header.getEndTimestamp().longValue());
+
+ // uniqueKey=false matches against the message keys index instead
+ get(adminService.queryMessage(ADDR, TOPIC, "key-1", 10, 0L, System.currentTimeMillis(), false, true, TIMEOUT));
+ verify(mqClientAdmin).queryMessage(eq(ADDR), eq(false), eq(true),
+ any(QueryMessageRequestHeader.class), eq(TIMEOUT));
+
+ // decompressBody=false: VerifyMessage relays the message, so it wants the body as stored
+ get(adminService.queryMessage(ADDR, TOPIC, "UNIQ-1", 1, 0L, Long.MAX_VALUE, true, false, TIMEOUT));
+ verify(mqClientAdmin).queryMessage(eq(ADDR), eq(true), eq(false),
+ any(QueryMessageRequestHeader.class), eq(TIMEOUT));
+ }
+
+ @Test
+ public void viewMessageBuildsHeaderTest() throws Exception {
+ MessageExt messageExt = new MessageExt();
+ messageExt.setMsgId("m1");
+ when(mqClientAdmin.viewMessage(eq(ADDR), any(ViewMessageRequestHeader.class), eq(TIMEOUT)))
+ .thenReturn(CompletableFuture.completedFuture(messageExt));
+
+ MessageExt result = get(adminService.viewMessage(ADDR, TOPIC, 12345L, TIMEOUT));
+
+ assertEquals("m1", result.getMsgId());
+ ArgumentCaptor<ViewMessageRequestHeader> captor = ArgumentCaptor.forClass(ViewMessageRequestHeader.class);
+ verify(mqClientAdmin).viewMessage(eq(ADDR), captor.capture(), eq(TIMEOUT));
+ assertEquals(TOPIC, captor.getValue().getTopic());
+ assertEquals(12345L, captor.getValue().getOffset().longValue());
+ }
+
+ @Test
+ public void searchOffsetByTimestampRunsBlockingCallOnExecutorTest() throws Exception {
+ MessageQueue messageQueue = new MessageQueue(TOPIC, "broker-a", 0);
+ when(mqClientAPIExt.searchOffset(ADDR, messageQueue, 1234L, TIMEOUT)).thenReturn(42L);
+
+ Long result = get(adminService.searchOffsetByTimestamp(ADDR, messageQueue, 1234L, TIMEOUT));
+
+ assertEquals(42L, result.longValue());
+ }
+
+ @Test
+ public void topicExistReflectsRouteLookupTest() throws Exception {
+ // a resolvable route means the topic exists
+ when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq(TOPIC), anyLong())).thenReturn(new TopicRouteData());
+ assertTrue(adminService.topicExist(TOPIC));
+
+ // a route lookup failure is treated as "does not exist"
+ when(mqClientAPIExt.getTopicRouteInfoFromNameServer(anyString(), anyLong()))
+ .thenThrow(new MQClientException("No route info of this topic", null));
+ assertFalse(adminService.topicExist(TOPIC));
+ }
+}
diff --git a/rocketmq-apis b/rocketmq-apis
new file mode 160000
index 0000000000..3e60073c6d
--- /dev/null
+++ b/rocketmq-apis
@@ -0,0 +1 @@
+Subproject commit 3e60073c6dab3430feaec443824802e23ecda681
diff --git a/rocketmq-proto/BUILD.bazel b/rocketmq-proto/BUILD.bazel
new file mode 100644
index 0000000000..3d6108fd38
--- /dev/null
+++ b/rocketmq-proto/BUILD.bazel
@@ -0,0 +1,92 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Protocol classes generated at build time from the rocketmq-apis git submodule.
+#
+# The admin contract (admin.proto) is not part of any rocketmq-proto
+# artifact published to Maven Central, so the Java + gRPC classes are generated
+# here from the submodule's .proto sources rather than resolved as a Maven
+# artifact. This mirrors what the rocketmq-proto Maven module does for the Maven
+# build.
+
+genrule(
+ name = "rocketmq_proto_srcjar",
+ srcs = [
+ "@com_google_protobuf_wkt//:wkt_protos",
+ "@rocketmq_apis//:v2_protos",
+ ],
+ tools = [
+ "@com_google_protobuf_protoc_linux_x86_64//file",
+ "@com_google_protobuf_protoc_osx_x86_64//file",
+ "@io_grpc_protoc_gen_grpc_java_linux_x86_64//file",
+ "@io_grpc_protoc_gen_grpc_java_osx_x86_64//file",
+ ],
+ outs = ["rocketmq_proto.srcjar"],
+ cmd = """
+ set -e
+ case "$$(uname -s)" in
+ Linux)
+ PROTOC="$$(pwd)/$(location @com_google_protobuf_protoc_linux_x86_64//file)"
+ PLUGIN="$$(pwd)/$(location @io_grpc_protoc_gen_grpc_java_linux_x86_64//file)"
+ ;;
+ Darwin)
+ PROTOC="$$(pwd)/$(location @com_google_protobuf_protoc_osx_x86_64//file)"
+ PLUGIN="$$(pwd)/$(location @io_grpc_protoc_gen_grpc_java_osx_x86_64//file)"
+ ;;
+ *)
+ echo "unsupported platform for proto codegen: $$(uname -s)" >&2
+ exit 1
+ ;;
+ esac
+ PT="$$(pwd)/$(@D)/proto_tree"
+ JAVA_OUT="$$(pwd)/$(@D)/java_out"
+ OUT="$$(pwd)/$@"
+ rm -rf $$PT $$JAVA_OUT
+ mkdir -p $$PT/apache/rocketmq/v2 $$PT/google/protobuf $$JAVA_OUT
+ for f in $(SRCS); do
+ case "$$f" in
+ */apache/rocketmq/v2/*.proto) cp "$$f" $$PT/apache/rocketmq/v2/ ;;
+ *) cp "$$f" $$PT/google/protobuf/ ;;
+ esac
+ done
+ $$PROTOC \
+ --plugin=protoc-gen-grpc-java=$$PLUGIN \
+ -I$$PT \
+ --java_out=$$JAVA_OUT \
+ --grpc-java_out=$$JAVA_OUT \
+ $$PT/apache/rocketmq/v2/*.proto
+ if command -v jar > /dev/null 2>&1; then
+ (cd $$JAVA_OUT && jar cf $$OUT .)
+ else
+ (cd $$JAVA_OUT && zip -qr $$OUT .)
+ fi
+ """,
+)
+
+java_library(
+ name = "rocketmq-proto",
+ srcs = [":rocketmq_proto_srcjar"],
+ visibility = ["//visibility:public"],
+ deps = [
+ "@maven//:com_google_guava_guava",
+ "@maven//:com_google_protobuf_protobuf_java",
+ "@maven//:io_grpc_grpc_api",
+ "@maven//:io_grpc_grpc_protobuf",
+ "@maven//:io_grpc_grpc_stub",
+ "@maven//:javax_annotation_javax_annotation_api",
+ ],
+)
diff --git a/rocketmq-proto/pom.xml b/rocketmq-proto/pom.xml
new file mode 100644
index 0000000000..cfb094236c
--- /dev/null
+++ b/rocketmq-proto/pom.xml
@@ -0,0 +1,143 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <parent>
+ <artifactId>rocketmq-all</artifactId>
+ <groupId>org.apache.rocketmq</groupId>
+ <version>${revision}</version>
+ </parent>
+
+ <modelVersion>4.0.0</modelVersion>
+ <packaging>jar</packaging>
+
+ <artifactId>rocketmq-proto</artifactId>
+ <name>rocketmq-proto ${project.version}</name>
+ <description>Apache RocketMQ protocol APIs. Generated at build time from the rocketmq-apis
+ git submodule (apache/rocketmq/v2/*.proto), so the admin surface is consumed from
+ source rather than from a published rocketmq-proto artifact.</description>
+
+ <properties>
+ <maven.compiler.source>8</maven.compiler.source>
+ <maven.compiler.target>8</maven.compiler.target>
+ <!-- generated code: skip static analysis / javadoc -->
+ <spotbugs.skip>true</spotbugs.skip>
+ <maven.javadoc.skip>true</maven.javadoc.skip>
+ <checkstyle.skip>true</checkstyle.skip>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.google.protobuf</groupId>
+ <artifactId>protobuf-java</artifactId>
+ <version>${protobuf.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>io.grpc</groupId>
+ <artifactId>grpc-api</artifactId>
+ <version>${grpc.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>io.grpc</groupId>
+ <artifactId>grpc-core</artifactId>
+ <version>${grpc.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>io.grpc</groupId>
+ <artifactId>grpc-protobuf</artifactId>
+ <version>${grpc.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>io.grpc</groupId>
+ <artifactId>grpc-stub</artifactId>
+ <version>${grpc.version}</version>
+ </dependency>
+ <!-- javax.annotation.Generated is referenced by the generated gRPC code
+ and only exists in the JDK 8 runtime -->
+ <dependency>
+ <groupId>javax.annotation</groupId>
+ <artifactId>javax.annotation-api</artifactId>
+ <version>1.3.2</version>
+ <scope>provided</scope>
+ </dependency>
+ <!-- generation-time only: google/protobuf/{timestamp,duration}.proto imports -->
+ <dependency>
+ <groupId>com.google.api.grpc</groupId>
+ <artifactId>proto-google-common-protos</artifactId>
+ <version>2.9.0</version>
+ <scope>provided</scope>
+ <exclusions>
+ <!-- supplied directly at ${protobuf.version}; avoid convergence conflict -->
+ <exclusion>
+ <groupId>com.google.protobuf</groupId>
+ <artifactId>protobuf-java</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <extensions>
+ <extension>
+ <groupId>kr.motd.maven</groupId>
+ <artifactId>os-maven-plugin</artifactId>
+ <version>1.7.1</version>
+ </extension>
+ </extensions>
+ <plugins>
+ <!-- Generated at build time from the rocketmq-apis submodule. This module only
+ participates in the reactor build and is NOT published to Maven Central:
+ the org.apache.rocketmq:rocketmq-proto coordinate is owned by the
+ rocketmq-apis repository (2.x versions), and deploying it here under
+ ${revision} (e.g. 5.5.1) would collide with that coordinate. -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-deploy-plugin</artifactId>
+ <configuration>
+ <skip>true</skip>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.xolstice.maven.plugins</groupId>
+ <artifactId>protobuf-maven-plugin</artifactId>
+ <version>0.6.1</version>
+ <configuration>
+ <!-- proto sources come from the rocketmq-apis git submodule -->
+ <protoSourceRoot>${project.basedir}/../rocketmq-apis</protoSourceRoot>
+ <includes>
+ <include>apache/rocketmq/v2/definition.proto</include>
+ <include>apache/rocketmq/v2/service.proto</include>
+ <include>apache/rocketmq/v2/admin.proto</include>
+ </includes>
+ <protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
+ <pluginId>grpc-java</pluginId>
+ <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
+ </configuration>
+ <executions>
+ <execution>
+ <goals>
+ <goal>compile</goal>
+ <goal>compile-custom</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/test/BUILD.bazel b/test/BUILD.bazel
index eb3e079596..37510e23a6 100644
--- a/test/BUILD.bazel
+++ b/test/BUILD.bazel
@@ -80,7 +80,7 @@ java_library(
"@maven//:io_grpc_grpc_testing",
"@maven//:io_netty_netty_all",
"@maven//:org_apache_commons_commons_lang3",
- "@maven//:org_apache_rocketmq_rocketmq_proto",
+ "//rocketmq-proto:rocketmq-proto",
"@maven//:io_github_aliyunmq_rocketmq_slf4j_api",
],
)