feat(rocketmq): improve connections, topics, and message queries

This commit is contained in:
AiLi 2026-08-04 01:38:29 +08:00 committed by GitHub
parent 0ece86c478
commit ce7b8c7b74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1166 additions and 407 deletions

1
.gitattributes vendored
View File

@ -1,6 +1,7 @@
# Keep formatter-sensitive source files consistent across platforms. # Keep formatter-sensitive source files consistent across platforms.
apps/desktop/src/**/*.ts text eol=lf apps/desktop/src/**/*.ts text eol=lf
apps/desktop/src/**/*.vue text eol=lf apps/desktop/src/**/*.vue text eol=lf
src-tauri/windows/nsis/**/*.nsi text eol=lf
# Keep tests available for review and CI without counting them as shipped code # Keep tests available for review and CI without counting them as shipped code
# in GitHub's repository language breakdown. # in GitHub's repository language breakdown.

View File

@ -61,6 +61,9 @@ public final class RocketMqAgent {
private static final int DEFAULT_LIST_LIMIT = 200; private static final int DEFAULT_LIST_LIMIT = 200;
private static final int CONSUMER_GROUP_ENRICH_CONCURRENCY = 8; private static final int CONSUMER_GROUP_ENRICH_CONCURRENCY = 8;
private static final long CONSUMER_GROUP_ENRICH_BUDGET_MS = 8_000; private static final long CONSUMER_GROUP_ENRICH_BUDGET_MS = 8_000;
/** Nameserver route enrichment for listTopics fallback when bulk broker config is unavailable. */
private static final int TOPIC_LIST_ROUTE_CONCURRENCY = 8;
private static final long TOPIC_LIST_ROUTE_BUDGET_MS = 8_000;
private static final String AUTO_CREATE_TOPIC_KEY = "TBW102"; private static final String AUTO_CREATE_TOPIC_KEY = "TBW102";
/** RocketMQ 5.x topic attribute key for message type (NORMAL/DELAY/FIFO/TRANSACTION). */ /** RocketMQ 5.x topic attribute key for message type (NORMAL/DELAY/FIFO/TRANSACTION). */
private static final String TOPIC_MESSAGE_TYPE_ATTRIBUTE = "message.type"; private static final String TOPIC_MESSAGE_TYPE_ATTRIBUTE = "message.type";
@ -364,47 +367,35 @@ public final class RocketMqAgent {
private static Object connect(JsonObject params) throws Exception { private static Object connect(JsonObject params) throws Exception {
JsonObject conn = connectionObject(params); JsonObject conn = connectionObject(params);
DefaultMQAdminExt nextAdmin = null; DefaultMQAdminExt nextAdmin = null;
DefaultMQProducer nextProducer = null;
try { try {
nextAdmin = buildAdminClient(conn); nextAdmin = buildAdminClient(conn);
nextAdmin.examineBrokerClusterInfo(); // One ClusterInfo per connect reuse for name/broker resolution and test payload.
nextProducer = buildProducer(conn); ClusterInfo clusterInfo = nextAdmin.examineBrokerClusterInfo();
closeClients(); closeClients();
adminClient = nextAdmin; adminClient = nextAdmin;
producer = nextProducer;
cachedConnection = conn.deepCopy(); cachedConnection = conn.deepCopy();
cachedClusterName = resolveClusterName(nextAdmin, conn); cachedClusterName = resolveClusterName(clusterInfo, conn);
cachedBrokerAddr = resolveBrokerAddr(nextAdmin, conn); cachedBrokerAddr = resolveBrokerAddr(nextAdmin, conn, clusterInfo);
return Collections.singletonMap("ok", true); return buildClusterTestResult(clusterInfo, nextAdmin, conn);
} catch (Exception e) { } catch (Exception e) {
if (nextAdmin != null) { if (nextAdmin != null) {
nextAdmin.shutdown(); nextAdmin.shutdown();
} }
if (nextProducer != null) {
nextProducer.shutdown();
}
throw e; throw e;
} }
} }
private static Object testConnection(JsonObject params) throws Exception { private static Object testConnection(JsonObject params) throws Exception {
JsonObject conn = connectionObject(params); JsonObject conn = connectionObject(params);
if (adminClient != null && cachedConnection != null && connectionMatches(cachedConnection, conn)) {
ClusterInfo clusterInfo = adminClient.examineBrokerClusterInfo();
return buildClusterTestResult(clusterInfo, adminClient, conn);
}
DefaultMQAdminExt probe = null; DefaultMQAdminExt probe = null;
try { try {
probe = buildAdminClient(conn); probe = buildAdminClient(conn);
ClusterInfo clusterInfo = probe.examineBrokerClusterInfo(); ClusterInfo clusterInfo = probe.examineBrokerClusterInfo();
String clusterName = resolveClusterName(clusterInfo, conn); return buildClusterTestResult(clusterInfo, probe, conn);
List<Map<String, Object>> brokers = brokerNodes(clusterInfo);
boolean aclEnabled = probeAclSupport(probe);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("clusterId", clusterName);
result.put("brokers", brokers);
result.put("nodeCount", brokers.size());
result.put("controller", brokers.isEmpty() ? null : brokers.get(0));
result.put("aclEnabled", aclEnabled);
return result;
} finally { } finally {
if (probe != null) { if (probe != null) {
probe.shutdown(); probe.shutdown();
@ -412,6 +403,33 @@ public final class RocketMqAgent {
} }
} }
private static Map<String, Object> buildClusterTestResult(
ClusterInfo clusterInfo,
DefaultMQAdminExt admin,
JsonObject conn
) throws Exception {
String clusterName = resolveClusterName(clusterInfo, conn);
List<Map<String, Object>> brokers = brokerNodes(clusterInfo);
boolean aclEnabled = probeAclSupport(admin, clusterInfo);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("clusterId", clusterName);
result.put("brokers", brokers);
result.put("nodeCount", brokers.size());
result.put("controller", brokers.isEmpty() ? null : brokers.get(0));
result.put("aclEnabled", aclEnabled);
return result;
}
static boolean connectionMatches(JsonObject cached, JsonObject requested) {
return namesrvAddr(cached).equals(namesrvAddr(requested))
&& clusterName(cached).equals(clusterName(requested))
&& brokerAddress(cached).equals(brokerAddress(requested))
&& credential(cached, "access_key", "accessKey").equals(credential(requested, "access_key", "accessKey"))
&& credential(cached, "secret_key", "secretKey").equals(credential(requested, "secret_key", "secretKey"));
}
private static void closeClients() { private static void closeClients() {
if (adminClient != null) { if (adminClient != null) {
adminClient.shutdown(); adminClient.shutdown();
@ -430,47 +448,129 @@ public final class RocketMqAgent {
DefaultMQAdminExt admin = requireAdmin(); DefaultMQAdminExt admin = requireAdmin();
String keyword = stringOrEmpty(params, "keyword").toLowerCase(Locale.ROOT); String keyword = stringOrEmpty(params, "keyword").toLowerCase(Locale.ROOT);
int offset = Math.max(0, intOrDefault(params, "offset", 0)); int offset = Math.max(0, intOrDefault(params, "offset", 0));
// limit <= 0: return all rows from offset (legacy single-shot signal).
// Large positive limits (e.g. Integer.MAX_VALUE) also return the full remaining
// catalog and stay compatible with older agents that coerced limit<=0 to 200.
int limit = intOrDefault(params, "limit", DEFAULT_LIST_LIMIT); int limit = intOrDefault(params, "limit", DEFAULT_LIST_LIMIT);
if (limit <= 0) { JsonObject conn = connectionObject(params);
limit = DEFAULT_LIST_LIMIT;
// One ClusterInfo per list call avoid repeated examineBrokerClusterInfo in helpers.
ClusterInfo clusterInfo = admin.examineBrokerClusterInfo();
Set<String> brokerSystemTopics = collectBrokerSystemTopics(admin);
Set<String> brokerNames = collectBrokerNames(clusterInfo);
String cluster = resolveClusterName(clusterInfo, conn);
BrokerTopicConfigSnapshot brokerSnapshot = collectBrokerTopicConfigSnapshot(admin, conn, clusterInfo);
Map<String, TopicConfig> brokerTopics = brokerSnapshot.topics();
Map<String, Map<String, String>> topicAttributes = topicAttributesFromConfigs(brokerTopics);
Set<String> nameserverTopics = Set.of();
if (!brokerSnapshot.complete() || brokerTopics.isEmpty()) {
// Reuse resolved cluster do not call examineBrokerClusterInfo again via clusterName(conn, admin).
TopicList topicList = fetchTopicList(admin, cluster);
nameserverTopics = topicList.getTopicList();
}
Set<String> topicNames = topicCatalogNames(brokerSnapshot, nameserverTopics);
List<Map<String, Object>> topics = buildTopicCatalogRows(
topicNames,
brokerTopics,
topicAttributes,
brokerSystemTopics,
brokerNames,
cluster,
keyword,
admin::examineTopicRouteInfo,
TOPIC_LIST_ROUTE_CONCURRENCY,
TOPIC_LIST_ROUTE_BUDGET_MS
);
topics.sort(Comparator.comparing(m -> String.valueOf(m.get("name"))));
int total = topics.size();
List<Map<String, Object>> page = paginate(topics, offset, limit);
Map<String, Object> result = new LinkedHashMap<>();
result.put("topics", page);
result.put("total", total);
result.put("offset", offset);
result.put("limit", limit);
return result;
}
@FunctionalInterface
interface TopicRouteLookup {
TopicRouteData load(String topic) throws Exception;
}
@FunctionalInterface
interface BrokerTopicConfigLookup {
TopicConfigSerializeWrapper load(String brokerAddr) throws Exception;
}
static final class BrokerTopicConfigSnapshot {
private final Map<String, TopicConfig> topics;
private final boolean complete;
private BrokerTopicConfigSnapshot(Map<String, TopicConfig> topics, boolean complete) {
this.topics = topics;
this.complete = complete;
} }
TopicList topicList = fetchTopicList(admin, connectionObject(params)); Map<String, TopicConfig> topics() {
Set<String> brokerSystemTopics = collectBrokerSystemTopics(admin); return topics;
Set<String> brokerNames = collectBrokerNames(admin); }
String cluster = clusterName(params, admin);
JsonObject conn = connectionObject(params); boolean complete() {
Map<String, TopicConfig> brokerTopics = collectBrokerTopicConfigs(admin, conn); return complete;
Map<String, Map<String, String>> topicAttributes = topicAttributesFromConfigs(brokerTopics); }
// Prefer broker topic configs (Dashboard ground truth); nameserver routes can outlive broker deletion. }
Set<String> topicNames = brokerTopics.isEmpty()
? new TreeSet<>(topicList.getTopicList()) static Set<String> topicCatalogNames(
: brokerTopics.keySet(); BrokerTopicConfigSnapshot brokerSnapshot, Set<String> nameserverTopics) {
if (brokerSnapshot.complete() && !brokerSnapshot.topics().isEmpty()) {
return new TreeSet<>(brokerSnapshot.topics().keySet());
}
Set<String> topics = new TreeSet<>(brokerSnapshot.topics().keySet());
topics.addAll(nameserverTopics);
return topics;
}
/**
* Build topic catalog rows from names + optional bulk broker configs.
* When bulk configs are empty, partition counts are filled via a single budgeted route RPC
* per topic; per-topic examineTopicConfig / resolveTopicMessageType is never used (that path
* hits the same broker that already failed getAllTopicConfig and can stall for minutes).
*/
static List<Map<String, Object>> buildTopicCatalogRows(
Set<String> topicNames,
Map<String, TopicConfig> brokerTopics,
Map<String, Map<String, String>> topicAttributes,
Set<String> brokerSystemTopics,
Set<String> brokerNames,
String cluster,
String keyword,
TopicRouteLookup routeLookup,
int routeConcurrency,
long routeBudgetMs
) {
String keywordLower = keyword == null ? "" : keyword.toLowerCase(Locale.ROOT);
boolean hasBrokerCatalog = brokerTopics != null && !brokerTopics.isEmpty();
List<Map<String, Object>> topics = new ArrayList<>(); List<Map<String, Object>> topics = new ArrayList<>();
List<Map<String, Object>> needsRoute = new ArrayList<>();
for (String topic : topicNames) { for (String topic : topicNames) {
if (!keyword.isBlank() && !topic.toLowerCase(Locale.ROOT).contains(keyword)) { if (!keywordLower.isBlank() && !topic.toLowerCase(Locale.ROOT).contains(keywordLower)) {
continue; continue;
} }
TopicConfig brokerConfig = brokerTopics.get(topic); TopicConfig brokerConfig = hasBrokerCatalog ? brokerTopics.get(topic) : null;
int partitions = brokerConfig == null ? 1 : Math.max(brokerConfig.getReadQueueNums(), 1); // Nameserver-only stale topics are skipped when broker catalog is the source of truth.
if (brokerConfig == null) { if (hasBrokerCatalog && brokerConfig == null) {
try { continue;
TopicRouteData route = admin.examineTopicRouteInfo(topic);
if (route.getQueueDatas() != null && !route.getQueueDatas().isEmpty()) {
partitions = Math.max(route.getQueueDatas().get(0).getReadQueueNums(), 1);
}
} catch (Exception ignored) {
// Stale nameserver-only topics are skipped below when broker configs are available.
if (!brokerTopics.isEmpty()) {
continue;
}
}
} }
Map<String, String> attributes = topicAttributes.get(topic); int partitions = brokerConfig == null ? 1 : Math.max(brokerConfig.getReadQueueNums(), 1);
if (isUserTopic(topic) && readTopicMessageTypeAttribute(attributes) == null) { Map<String, String> attributes = topicAttributes == null ? null : topicAttributes.get(topic);
String resolved = brokerConfig == null // Type from bulk config attributes only never examineTopicConfig per topic on fallback.
? resolveTopicMessageType(admin, conn, topic) if (brokerConfig != null
: readTopicMessageType(brokerConfig); && isUserTopic(topic)
&& readTopicMessageTypeAttribute(attributes) == null) {
String resolved = readTopicMessageType(brokerConfig);
if (resolved != null && !resolved.isBlank()) { if (resolved != null && !resolved.isBlank()) {
attributes = attributes == null ? new HashMap<>() : new HashMap<>(attributes); attributes = attributes == null ? new HashMap<>() : new HashMap<>(attributes);
attributes.put("+" + TOPIC_MESSAGE_TYPE_ATTRIBUTE, resolved); attributes.put("+" + TOPIC_MESSAGE_TYPE_ATTRIBUTE, resolved);
@ -488,17 +588,71 @@ public final class RocketMqAgent {
row.put("internal", internal); row.put("internal", internal);
row.put("messageType", messageType); row.put("messageType", messageType);
topics.add(row); topics.add(row);
if (brokerConfig == null) {
needsRoute.add(row);
}
} }
topics.sort(Comparator.comparing(m -> String.valueOf(m.get("name")))); if (!needsRoute.isEmpty() && routeLookup != null) {
enrichTopicPartitions(needsRoute, routeLookup, routeConcurrency, routeBudgetMs);
}
return topics;
}
int total = topics.size(); static int partitionsFromRoute(TopicRouteData route) {
List<Map<String, Object>> page = paginate(topics, offset, limit); if (route == null || route.getQueueDatas() == null || route.getQueueDatas().isEmpty()) {
Map<String, Object> result = new LinkedHashMap<>(); return 1;
result.put("topics", page); }
result.put("total", total); return Math.max(route.getQueueDatas().get(0).getReadQueueNums(), 1);
result.put("offset", offset); }
result.put("limit", limit);
return result; /**
* Fill {@code partitions} via nameserver route lookups with bounded concurrency and a global
* time budget. Unfinished/failed topics keep the default partitions=1.
*/
static void enrichTopicPartitions(
List<Map<String, Object>> rows,
TopicRouteLookup lookup,
int concurrency,
long budgetMs) {
if (rows.isEmpty() || lookup == null) {
return;
}
int workers = Math.max(1, Math.min(concurrency, rows.size()));
ExecutorService executor = Executors.newFixedThreadPool(workers, runnable -> {
Thread thread = new Thread(runnable, "dbx-rocketmq-topic-route");
thread.setDaemon(true);
return thread;
});
List<Callable<Integer>> tasks = new ArrayList<>(rows.size());
for (Map<String, Object> row : rows) {
String topic = String.valueOf(row.get("name"));
tasks.add(() -> {
try {
return partitionsFromRoute(lookup.load(topic));
} catch (Exception ignored) {
return 1;
}
});
}
List<Future<Integer>> results = Collections.emptyList();
try {
results = executor.invokeAll(tasks, Math.max(1, budgetMs), TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
executor.shutdownNow();
}
for (int index = 0; index < rows.size(); index++) {
Map<String, Object> row = rows.get(index);
if (index < results.size() && !results.get(index).isCancelled()) {
try {
row.put("partitions", results.get(index).get());
} catch (Exception ignored) {
// Keep default partitions=1 when enrichment fails.
}
}
row.putIfAbsent("partitions", 1);
}
} }
private static Object createTopic(JsonObject params) throws Exception { private static Object createTopic(JsonObject params) throws Exception {
@ -1819,9 +1973,8 @@ public final class RocketMqAgent {
return addr; return addr;
} }
private static TopicList fetchTopicList(DefaultMQAdminExt admin, JsonObject conn) throws Exception { private static TopicList fetchTopicList(DefaultMQAdminExt admin, String cluster) throws Exception {
String cluster = clusterName(conn, admin); if (cluster != null && !cluster.isBlank()) {
if (!cluster.isBlank()) {
return admin.fetchTopicsByCLuster(cluster); return admin.fetchTopicsByCLuster(cluster);
} }
return admin.fetchAllTopicList(); return admin.fetchAllTopicList();
@ -1865,32 +2018,45 @@ public final class RocketMqAgent {
static List<String> resolveMasterBrokerAddrs( static List<String> resolveMasterBrokerAddrs(
DefaultMQAdminExt admin, JsonObject conn, String brokerNameFilter) throws Exception { DefaultMQAdminExt admin, JsonObject conn, String brokerNameFilter) throws Exception {
ClusterInfo clusterInfo = admin.examineBrokerClusterInfo(); return resolveMasterBrokerAddrs(admin, conn, brokerNameFilter, admin.examineBrokerClusterInfo());
LinkedHashSet<String> addrs = new LinkedHashSet<>(); }
if (clusterInfo.getBrokerAddrTable() != null) {
for (BrokerData broker : clusterInfo.getBrokerAddrTable().values()) { static List<String> resolveMasterBrokerAddrs(
if (brokerNameFilter != null && !brokerNameFilter.isBlank() DefaultMQAdminExt admin, JsonObject conn, String brokerNameFilter, ClusterInfo clusterInfo)
&& !brokerNameFilter.equals(broker.getBrokerName())) { throws Exception {
continue; LinkedHashSet<String> addrs = masterBrokerAddrsFromClusterInfo(clusterInfo, conn, brokerNameFilter);
}
String masterAddr = null;
if (broker.getBrokerAddrs() != null && broker.getBrokerAddrs().containsKey(0L)) {
masterAddr = broker.getBrokerAddrs().get(0L);
}
if (masterAddr == null || masterAddr.isBlank()) {
masterAddr = broker.selectBrokerAddr();
}
if (masterAddr != null && !masterAddr.isBlank()) {
addrs.add(remapBrokerAddrForClient(masterAddr, conn));
}
}
}
if (addrs.isEmpty()) { if (addrs.isEmpty()) {
addrs.add(resolveBrokerAddr(admin, conn)); addrs.add(resolveBrokerAddr(admin, conn, clusterInfo));
} }
return new ArrayList<>(addrs); return new ArrayList<>(addrs);
} }
/** Extract remapped master broker addresses from a ClusterInfo snapshot. */
static LinkedHashSet<String> masterBrokerAddrsFromClusterInfo(
ClusterInfo clusterInfo, JsonObject conn, String brokerNameFilter) {
LinkedHashSet<String> addrs = new LinkedHashSet<>();
if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
return addrs;
}
for (BrokerData broker : clusterInfo.getBrokerAddrTable().values()) {
if (brokerNameFilter != null && !brokerNameFilter.isBlank()
&& !brokerNameFilter.equals(broker.getBrokerName())) {
continue;
}
String masterAddr = null;
if (broker.getBrokerAddrs() != null && broker.getBrokerAddrs().containsKey(0L)) {
masterAddr = broker.getBrokerAddrs().get(0L);
}
if (masterAddr == null || masterAddr.isBlank()) {
masterAddr = broker.selectBrokerAddr();
}
if (masterAddr != null && !masterAddr.isBlank()) {
addrs.add(remapBrokerAddrForClient(masterAddr, conn));
}
}
return addrs;
}
private static void applyTopicConfigValue(TopicConfig config, String key, String value) { private static void applyTopicConfigValue(TopicConfig config, String key, String value) {
if (value == null) { if (value == null) {
return; return;
@ -2071,9 +2237,11 @@ public final class RocketMqAgent {
}; };
} }
private static boolean probeAclSupport(DefaultMQAdminExt admin) { private static boolean probeAclSupport(DefaultMQAdminExt admin, ClusterInfo clusterInfo) {
try { try {
ClusterInfo clusterInfo = admin.examineBrokerClusterInfo(); if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
return false;
}
for (BrokerData broker : clusterInfo.getBrokerAddrTable().values()) { for (BrokerData broker : clusterInfo.getBrokerAddrTable().values()) {
String brokerAddr = broker.selectBrokerAddr(); String brokerAddr = broker.selectBrokerAddr();
if (brokerAddr == null || brokerAddr.isBlank()) { if (brokerAddr == null || brokerAddr.isBlank()) {
@ -2101,22 +2269,30 @@ public final class RocketMqAgent {
if (!configured.isBlank()) { if (!configured.isBlank()) {
return configured; return configured;
} }
if (clusterInfo.getClusterAddrTable() != null && !clusterInfo.getClusterAddrTable().isEmpty()) { if (clusterInfo != null
&& clusterInfo.getClusterAddrTable() != null
&& !clusterInfo.getClusterAddrTable().isEmpty()) {
return clusterInfo.getClusterAddrTable().keySet().iterator().next(); return clusterInfo.getClusterAddrTable().keySet().iterator().next();
} }
return "DefaultCluster"; return "DefaultCluster";
} }
private static String resolveBrokerAddr(DefaultMQAdminExt admin, JsonObject conn) throws Exception { private static String resolveBrokerAddr(DefaultMQAdminExt admin, JsonObject conn) throws Exception {
return resolveBrokerAddr(admin, conn, admin.examineBrokerClusterInfo());
}
private static String resolveBrokerAddr(DefaultMQAdminExt admin, JsonObject conn, ClusterInfo clusterInfo)
throws Exception {
String explicit = brokerAddress(conn); String explicit = brokerAddress(conn);
if (!explicit.isBlank()) { if (!explicit.isBlank()) {
return explicit; return explicit;
} }
ClusterInfo clusterInfo = admin.examineBrokerClusterInfo(); if (clusterInfo != null && clusterInfo.getBrokerAddrTable() != null) {
for (BrokerData broker : clusterInfo.getBrokerAddrTable().values()) { for (BrokerData broker : clusterInfo.getBrokerAddrTable().values()) {
String addr = broker.selectBrokerAddr(); String addr = broker.selectBrokerAddr();
if (addr != null && !addr.isBlank()) { if (addr != null && !addr.isBlank()) {
return remapBrokerAddrForClient(addr, conn); return remapBrokerAddrForClient(addr, conn);
}
} }
} }
throw new IllegalStateException("No RocketMQ broker address found"); throw new IllegalStateException("No RocketMQ broker address found");
@ -2364,18 +2540,23 @@ public final class RocketMqAgent {
} }
private static Set<String> collectBrokerNames(DefaultMQAdminExt admin) { private static Set<String> collectBrokerNames(DefaultMQAdminExt admin) {
Set<String> names = new HashSet<>();
try { try {
ClusterInfo clusterInfo = admin.examineBrokerClusterInfo(); return collectBrokerNames(admin.examineBrokerClusterInfo());
if (clusterInfo.getBrokerAddrTable() != null) {
for (BrokerData brokerData : clusterInfo.getBrokerAddrTable().values()) {
if (brokerData.getBrokerName() != null && !brokerData.getBrokerName().isBlank()) {
names.add(brokerData.getBrokerName());
}
}
}
} catch (Exception ignored) { } catch (Exception ignored) {
// Fall back to static reserved-topic filtering only. // Fall back to static reserved-topic filtering only.
return Set.of();
}
}
static Set<String> collectBrokerNames(ClusterInfo clusterInfo) {
Set<String> names = new HashSet<>();
if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
return names;
}
for (BrokerData brokerData : clusterInfo.getBrokerAddrTable().values()) {
if (brokerData.getBrokerName() != null && !brokerData.getBrokerName().isBlank()) {
names.add(brokerData.getBrokerName());
}
} }
return names; return names;
} }
@ -2397,29 +2578,44 @@ public final class RocketMqAgent {
} }
private static Map<String, TopicConfig> collectBrokerTopicConfigs(DefaultMQAdminExt admin, JsonObject conn) { private static Map<String, TopicConfig> collectBrokerTopicConfigs(DefaultMQAdminExt admin, JsonObject conn) {
Map<String, TopicConfig> merged = new LinkedHashMap<>();
try { try {
for (String brokerAddr : resolveMasterBrokerAddrs(admin, conn)) { return collectBrokerTopicConfigSnapshot(admin, conn, admin.examineBrokerClusterInfo()).topics();
try { } catch (Exception ignored) {
TopicConfigSerializeWrapper wrapper = return new LinkedHashMap<>();
admin.getAllTopicConfig(brokerAddr, DEFAULT_REQUEST_TIMEOUT_MS); }
if (wrapper == null || wrapper.getTopicConfigTable() == null) { }
private static BrokerTopicConfigSnapshot collectBrokerTopicConfigSnapshot(
DefaultMQAdminExt admin, JsonObject conn, ClusterInfo clusterInfo) throws Exception {
List<String> brokerAddrs = resolveMasterBrokerAddrs(admin, conn, null, clusterInfo);
return collectBrokerTopicConfigs(
brokerAddrs,
brokerAddr -> admin.getAllTopicConfig(brokerAddr, DEFAULT_REQUEST_TIMEOUT_MS)
);
}
static BrokerTopicConfigSnapshot collectBrokerTopicConfigs(
List<String> brokerAddrs, BrokerTopicConfigLookup lookup) {
Map<String, TopicConfig> merged = new LinkedHashMap<>();
boolean complete = !brokerAddrs.isEmpty();
for (String brokerAddr : brokerAddrs) {
try {
TopicConfigSerializeWrapper wrapper = lookup.load(brokerAddr);
if (wrapper == null || wrapper.getTopicConfigTable() == null) {
complete = false;
continue;
}
for (TopicConfig config : wrapper.getTopicConfigTable().values()) {
if (config.getTopicName() == null || config.getTopicName().isBlank()) {
continue; continue;
} }
for (TopicConfig config : wrapper.getTopicConfigTable().values()) { merged.merge(config.getTopicName(), config, RocketMqAgent::preferTopicConfig);
if (config.getTopicName() == null || config.getTopicName().isBlank()) {
continue;
}
merged.merge(config.getTopicName(), config, RocketMqAgent::preferTopicConfig);
}
} catch (Exception ignored) {
// Some brokers may reject bulk config reads.
} }
} catch (Exception ignored) {
complete = false;
} }
} catch (Exception ignored) {
// Fall back to nameserver topic list in listTopics.
} }
return merged; return new BrokerTopicConfigSnapshot(merged, complete);
} }
private static TopicConfig preferTopicConfig(TopicConfig left, TopicConfig right) { private static TopicConfig preferTopicConfig(TopicConfig left, TopicConfig right) {
@ -2477,13 +2673,24 @@ public final class RocketMqAgent {
} }
private static String resolveTopicMessageType(DefaultMQAdminExt admin, JsonObject conn, String topic) { private static String resolveTopicMessageType(DefaultMQAdminExt admin, JsonObject conn, String topic) {
try {
return resolveTopicMessageType(admin, conn, topic, admin.examineBrokerClusterInfo());
} catch (Exception ignored) {
return null;
}
}
private static String resolveTopicMessageType(
DefaultMQAdminExt admin, JsonObject conn, String topic, ClusterInfo clusterInfo) {
try { try {
TopicRouteData route = admin.examineTopicRouteInfo(topic); TopicRouteData route = admin.examineTopicRouteInfo(topic);
if (route.getQueueDatas() == null || route.getQueueDatas().isEmpty()) { if (route.getQueueDatas() == null || route.getQueueDatas().isEmpty()) {
return null; return null;
} }
String brokerName = route.getQueueDatas().get(0).getBrokerName(); String brokerName = route.getQueueDatas().get(0).getBrokerName();
ClusterInfo clusterInfo = admin.examineBrokerClusterInfo(); if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
return null;
}
BrokerData brokerData = clusterInfo.getBrokerAddrTable().get(brokerName); BrokerData brokerData = clusterInfo.getBrokerAddrTable().get(brokerName);
if (brokerData == null) { if (brokerData == null) {
return null; return null;
@ -2499,10 +2706,17 @@ public final class RocketMqAgent {
} }
} }
/**
* Slice {@code items} from {@code offset}. When {@code limit <= 0}, return all remaining items
* (used by the Rust adapter's single-shot full list request).
*/
static <T> List<T> paginate(List<T> items, int offset, int limit) { static <T> List<T> paginate(List<T> items, int offset, int limit) {
if (offset >= items.size()) { if (offset >= items.size()) {
return Collections.emptyList(); return Collections.emptyList();
} }
if (limit <= 0) {
return new ArrayList<>(items.subList(offset, items.size()));
}
int end = Math.min(items.size(), offset + limit); int end = Math.min(items.size(), offset + limit);
return new ArrayList<>(items.subList(offset, end)); return new ArrayList<>(items.subList(offset, end));
} }
@ -2514,9 +2728,12 @@ public final class RocketMqAgent {
return adminClient; return adminClient;
} }
private static DefaultMQProducer requireProducer() { private static DefaultMQProducer requireProducer() throws Exception {
if (producer == null) { if (producer == null) {
throw new IllegalStateException("Producer is not initialized. Call connect first."); if (cachedConnection == null) {
throw new IllegalStateException("Not connected. Call connect first.");
}
producer = buildProducer(cachedConnection);
} }
return producer; return producer;
} }

View File

@ -14,17 +14,23 @@ import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
import org.apache.rocketmq.remoting.protocol.admin.TopicOffset; import org.apache.rocketmq.remoting.protocol.admin.TopicOffset;
import org.apache.rocketmq.remoting.protocol.body.ProducerInfo; import org.apache.rocketmq.remoting.protocol.body.ProducerInfo;
import org.apache.rocketmq.remoting.protocol.body.ProducerTableInfo; import org.apache.rocketmq.remoting.protocol.body.ProducerTableInfo;
import org.apache.rocketmq.remoting.protocol.body.TopicConfigSerializeWrapper;
import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection; import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
import org.apache.rocketmq.remoting.protocol.route.QueueData;
import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
import org.apache.rocketmq.common.message.MessageQueue; import org.apache.rocketmq.common.message.MessageQueue;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@ -180,6 +186,29 @@ class RocketMqAgentTest {
assertEquals(200, RocketMqAgent.paginate(items, 200, 200).get(0)); assertEquals(200, RocketMqAgent.paginate(items, 200, 200).get(0));
} }
@Test
void paginateLimitZeroReturnsAllFromOffset() {
List<Integer> items = IntStream.range(0, 341).boxed().toList();
assertEquals(341, RocketMqAgent.paginate(items, 0, 0).size());
assertEquals(141, RocketMqAgent.paginate(items, 200, 0).size());
assertEquals(200, RocketMqAgent.paginate(items, 200, 0).get(0));
}
@Test
void collectBrokerNamesFromClusterInfoSnapshot() {
org.apache.rocketmq.remoting.protocol.body.ClusterInfo clusterInfo =
new org.apache.rocketmq.remoting.protocol.body.ClusterInfo();
java.util.HashMap<String, org.apache.rocketmq.remoting.protocol.route.BrokerData> table =
new java.util.HashMap<>();
org.apache.rocketmq.remoting.protocol.route.BrokerData broker =
new org.apache.rocketmq.remoting.protocol.route.BrokerData();
broker.setBrokerName("broker-a");
table.put("broker-a", broker);
clusterInfo.setBrokerAddrTable(table);
assertEquals(Set.of("broker-a"), RocketMqAgent.collectBrokerNames(clusterInfo));
assertEquals(Set.of(), RocketMqAgent.collectBrokerNames(null));
}
@Test @Test
void resolveNameServerAddrSetSplitsMultiAddr() { void resolveNameServerAddrSetSplitsMultiAddr() {
JsonObject conn = JsonParser.parseString(""" JsonObject conn = JsonParser.parseString("""
@ -292,6 +321,187 @@ class RocketMqAgentTest {
} }
} }
@Test
void enrichTopicPartitionsRunsIndependentLookupsConcurrently() throws Exception {
List<Map<String, Object>> rows = IntStream.range(0, 4)
.mapToObj(index -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", "topic-" + index);
row.put("partitions", 1);
return row;
})
.toList();
CountDownLatch started = new CountDownLatch(rows.size());
CountDownLatch release = new CountDownLatch(1);
AtomicInteger active = new AtomicInteger();
AtomicInteger maxActive = new AtomicInteger();
CompletableFuture<Void> enrichment = CompletableFuture.runAsync(() ->
RocketMqAgent.enrichTopicPartitions(rows, topic -> {
int current = active.incrementAndGet();
maxActive.accumulateAndGet(current, Math::max);
started.countDown();
try {
release.await(1, TimeUnit.SECONDS);
return routeWithReadQueues(8);
} finally {
active.decrementAndGet();
}
}, 4, 1_000)
);
assertTrue(started.await(1, TimeUnit.SECONDS));
release.countDown();
enrichment.get(2, TimeUnit.SECONDS);
assertTrue(maxActive.get() > 1);
for (Map<String, Object> row : rows) {
assertEquals(8, row.get("partitions"));
}
}
@Test
void enrichTopicPartitionsReturnsDefaultsWhenBudgetExpires() {
List<Map<String, Object>> rows = IntStream.range(0, 4)
.mapToObj(index -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", "slow-topic-" + index);
row.put("partitions", 1);
return row;
})
.toList();
CountDownLatch blocked = new CountDownLatch(1);
long startedAt = System.nanoTime();
RocketMqAgent.enrichTopicPartitions(rows, topic -> {
blocked.await();
return routeWithReadQueues(16);
}, 2, 50);
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
assertTrue(elapsedMs < 1_000, "topic route enrichment exceeded its response budget: " + elapsedMs + "ms");
for (Map<String, Object> row : rows) {
assertEquals(1, row.get("partitions"));
}
}
@Test
void buildTopicCatalogRowsFallbackIssuesAtMostOneRouteRpcPerTopic() {
Set<String> topicNames = new TreeSet<>(List.of("Alpha", "Beta", "Gamma"));
Map<String, AtomicInteger> routeCalls = new ConcurrentHashMap<>();
AtomicInteger totalRouteCalls = new AtomicInteger();
List<Map<String, Object>> rows = RocketMqAgent.buildTopicCatalogRows(
topicNames,
Collections.emptyMap(),
Collections.emptyMap(),
Set.of(),
Set.of(),
"DefaultCluster",
"",
topic -> {
totalRouteCalls.incrementAndGet();
routeCalls.computeIfAbsent(topic, ignored -> new AtomicInteger()).incrementAndGet();
return routeWithReadQueues(4);
},
8,
2_000
);
assertEquals(3, rows.size());
assertEquals(3, totalRouteCalls.get(), "fallback must not repeat examineTopicRouteInfo per topic");
for (String topic : topicNames) {
assertEquals(1, routeCalls.get(topic).get());
}
for (Map<String, Object> row : rows) {
assertEquals(4, row.get("partitions"));
// Bulk config unavailable classify without per-topic examineTopicConfig.
assertEquals("UNSPECIFIED", row.get("messageType"));
}
}
@Test
void buildTopicCatalogRowsUsesBrokerConfigWithoutRouteLookup() {
TopicConfig config = new TopicConfig("Orders");
config.setReadQueueNums(12);
config.setAttributes(Map.of("message.type", "NORMAL"));
Map<String, TopicConfig> brokerTopics = Map.of("Orders", config);
AtomicInteger routeCalls = new AtomicInteger();
List<Map<String, Object>> rows = RocketMqAgent.buildTopicCatalogRows(
Set.of("Orders"),
brokerTopics,
Map.of("Orders", Map.of("message.type", "NORMAL")),
Set.of(),
Set.of(),
"DefaultCluster",
"",
topic -> {
routeCalls.incrementAndGet();
return routeWithReadQueues(99);
},
8,
2_000
);
assertEquals(1, rows.size());
assertEquals(0, routeCalls.get(), "broker catalog path must not call examineTopicRouteInfo");
assertEquals(12, rows.get(0).get("partitions"));
assertEquals("NORMAL", rows.get(0).get("messageType"));
}
@Test
void partialBrokerTopicConfigsMergeNameServerCatalog() {
TopicConfig orders = new TopicConfig("Orders");
TopicConfigSerializeWrapper wrapper = new TopicConfigSerializeWrapper();
wrapper.setTopicConfigTable(new ConcurrentHashMap<>(Map.of("Orders", orders)));
RocketMqAgent.BrokerTopicConfigSnapshot snapshot = RocketMqAgent.collectBrokerTopicConfigs(
List.of("broker-a", "broker-b"),
brokerAddr -> {
if (brokerAddr.equals("broker-b")) {
throw new IllegalStateException("broker unavailable");
}
return wrapper;
}
);
assertFalse(snapshot.complete());
assertEquals(Set.of("Orders"), snapshot.topics().keySet());
assertEquals(
Set.of("Invoices", "Orders"),
RocketMqAgent.topicCatalogNames(snapshot, Set.of("Invoices", "Orders"))
);
}
@Test
void completeBrokerTopicConfigsRemainAuthoritative() {
TopicConfig orders = new TopicConfig("Orders");
TopicConfigSerializeWrapper wrapper = new TopicConfigSerializeWrapper();
wrapper.setTopicConfigTable(new ConcurrentHashMap<>(Map.of("Orders", orders)));
RocketMqAgent.BrokerTopicConfigSnapshot snapshot = RocketMqAgent.collectBrokerTopicConfigs(
List.of("broker-a"),
brokerAddr -> wrapper
);
assertTrue(snapshot.complete());
assertEquals(
Set.of("Orders"),
RocketMqAgent.topicCatalogNames(snapshot, Set.of("DeletedButStillRouted", "Orders"))
);
}
private static TopicRouteData routeWithReadQueues(int readQueueNums) {
QueueData queueData = new QueueData();
queueData.setBrokerName("broker-a");
queueData.setReadQueueNums(readQueueNums);
queueData.setWriteQueueNums(readQueueNums);
TopicRouteData route = new TopicRouteData();
route.setQueueDatas(List.of(queueData));
return route;
}
@Test @Test
void isEmptyQueryMessageResultDetectsRocketMqCode208() { void isEmptyQueryMessageResultDetectsRocketMqCode208() {
MQClientException empty = new MQClientException(208, "query message by key finished, but no message"); MQClientException empty = new MQClientException(208, "query message by key finished, but no message");
@ -346,4 +556,23 @@ class RocketMqAgentTest {
assertEquals("CLIENT_INNER_PRODUCER", producers.get(0).get("producerName")); assertEquals("CLIENT_INNER_PRODUCER", producers.get(0).get("producerName"));
assertEquals("127.0.0.1:39688", producers.get(0).get("address")); assertEquals("127.0.0.1:39688", producers.get(0).get("address"));
} }
@Test
void connectionMatchesComparesRocketMqConnectFields() {
JsonObject base = JsonParser.parseString("""
{
"namesrv_addr": "127.0.0.1:9876",
"cluster_name": "DefaultCluster",
"broker_addr": "",
"access_key": "ak",
"secret_key": "sk"
}
""").getAsJsonObject();
JsonObject same = base.deepCopy();
JsonObject differentNamesrv = base.deepCopy();
differentNamesrv.addProperty("namesrv_addr", "127.0.0.1:9877");
assertTrue(RocketMqAgent.connectionMatches(base, same));
assertFalse(RocketMqAgent.connectionMatches(base, differentNamesrv));
}
} }

View File

@ -158,20 +158,25 @@ async function runQuery() {
if (!id) throw new Error(t("mqMessages.msgIdRequired")); if (!id) throw new Error(t("mqMessages.msgIdRequired"));
const result = await mqViewMessage(props.connectionId, topic, id); const result = await mqViewMessage(props.connectionId, topic, id);
queryMessages.value = parseRocketMqMessagesFromResult(result); queryMessages.value = parseRocketMqMessagesFromResult(result);
} else if (activeQueryMode.value === "key") {
const key = queryKey.value.trim();
if (!key) throw new Error(t("mqMessages.queryKeyRequired"));
// Dashboard key query only needs topic + key; broker returns up to 64 recent matches.
// Do not validate hidden Topic-mode time fields Key uses a fixed 0..now window.
const result = await mqQueryMessagesByKey(props.connectionId, topic, key, 0, Date.now(), 64);
queryMessages.value = parseRocketMqMessagesFromResult(result);
} else { } else {
const begin = queryBeginTime.value ? Date.parse(queryBeginTime.value) : 0; const begin = queryBeginTime.value ? Date.parse(queryBeginTime.value) : Date.parse(defaultTimeRange().begin);
const end = queryEndTime.value ? Date.parse(queryEndTime.value) : Date.now(); const end = queryEndTime.value ? Date.parse(queryEndTime.value) : Date.parse(defaultTimeRange().end);
const maxNum = Math.max(1, Math.min(200, Number(queryMaxNum.value) || 32)); if (!Number.isFinite(begin) || !Number.isFinite(end)) {
if (activeQueryMode.value === "key") { throw new Error(t("mqMessages.invalidTimeRange"));
const key = queryKey.value.trim();
if (!key) throw new Error(t("mqMessages.queryKeyRequired"));
// Dashboard key query only needs topic + key; broker returns up to 64 recent matches.
const result = await mqQueryMessagesByKey(props.connectionId, topic, key, 0, Date.now(), 64);
queryMessages.value = parseRocketMqMessagesFromResult(result);
} else {
const result = await mqQueryMessagesByTopic(props.connectionId, topic, begin, end, maxNum);
queryMessages.value = parseRocketMqMessagesFromResult(result);
} }
if (begin >= end) {
throw new Error(t("mqMessages.endTimeMustBeAfterBegin"));
}
const maxNum = Math.max(1, Math.min(200, Number(queryMaxNum.value) || 32));
const result = await mqQueryMessagesByTopic(props.connectionId, topic, begin, end, maxNum);
queryMessages.value = parseRocketMqMessagesFromResult(result);
} }
} catch (e: unknown) { } catch (e: unknown) {
queryError.value = formatError(e); queryError.value = formatError(e);
@ -401,20 +406,22 @@ watch(topicName, () => {
<input v-model="queryKey" type="text" :placeholder="t('mqMessages.queryKeyPlaceholder')" :disabled="queryLoading" /> <input v-model="queryKey" type="text" :placeholder="t('mqMessages.queryKeyPlaceholder')" :disabled="queryLoading" />
</label> </label>
<label v-else-if="activeQueryMode === 'topic'" class="filter-field"> <template v-else-if="activeQueryMode === 'topic'">
<span>{{ t("mqMessages.beginTime") }}</span> <label class="filter-field">
<input v-model="queryBeginTime" type="datetime-local" :disabled="queryLoading" /> <span>{{ t("mqMessages.beginTime") }}</span>
</label> <input v-model="queryBeginTime" type="datetime-local" :disabled="queryLoading" />
</label>
<label v-else-if="activeQueryMode === 'topic'" class="filter-field"> <label class="filter-field">
<span>{{ t("mqMessages.endTime") }}</span> <span>{{ t("mqMessages.endTime") }}</span>
<input v-model="queryEndTime" type="datetime-local" :disabled="queryLoading" /> <input v-model="queryEndTime" type="datetime-local" :disabled="queryLoading" />
</label> </label>
<label v-else-if="activeQueryMode === 'topic'" class="filter-field filter-narrow"> <label class="filter-field filter-narrow">
<span>{{ t("mqMessages.maxNum") }}</span> <span>{{ t("mqMessages.maxNum") }}</span>
<input v-model.number="queryMaxNum" type="number" min="1" max="200" :disabled="queryLoading" /> <input v-model.number="queryMaxNum" type="number" min="1" max="200" :disabled="queryLoading" />
</label> </label>
</template>
<div class="filter-actions"> <div class="filter-actions">
<button type="button" class="btn-primary" :disabled="queryLoading || !selectedTopicRef" @click="runQuery"> <button type="button" class="btn-primary" :disabled="queryLoading || !selectedTopicRef" @click="runQuery">

View File

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from "vue"; import { ref, watch, computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq"; import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api"; import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
import type { ClusterInfo } from "@/types/mq"; import type { ClusterInfo } from "@/types/mq";
@ -14,6 +16,13 @@ import { formatError } from "@/lib/backend/errorUtils";
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes"; import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
const TOPIC_ROW_HEIGHT = 44;
type VirtualTopicRow = {
id: string;
topic: TopicInfo;
};
interface Props { interface Props {
connectionId: string; connectionId: string;
tenant?: string; tenant?: string;
@ -110,6 +119,38 @@ const filteredTopics = computed(() => {
}); });
}); });
/** Stable ids for RecycleScroller; avoids mounting all topic rows at once. */
const virtualTopicRows = computed<VirtualTopicRow[]>(() =>
filteredTopics.value.map((topic) => ({
id: showNamespaceColumn.value ? `${topic.namespace ?? ""}:${topic.name}` : topic.name,
topic,
})),
);
const topicsGridTemplate = computed(() => {
const cols: string[] = ["minmax(180px, 1.6fr)"];
if (showNamespaceColumn.value) cols.push("minmax(100px, 0.8fr)");
cols.push("120px");
if (!isRocketMqCluster.value) cols.push("140px");
// RocketMQ keeps tiled row actions (status/route/consumers/) reserve a wider actions column.
cols.push(isRocketMqCluster.value ? "minmax(560px, 2.2fr)" : "minmax(200px, 1fr)");
return cols.join(" ");
});
/** Min content width from grid track mins + gaps + padding; enables shared horizontal scroll. */
const topicsTableMinWidthPx = computed(() => {
let min = 180 + 120; // name + type
if (showNamespaceColumn.value) min += 100;
if (!isRocketMqCluster.value) min += 140;
min += isRocketMqCluster.value ? 560 : 200;
let colCount = 3;
if (showNamespaceColumn.value) colCount += 1;
if (!isRocketMqCluster.value) colCount += 1;
min += (colCount - 1) * 8; // column-gap
min += 24; // horizontal padding
return min;
});
const userTopicCount = computed(() => { const userTopicCount = computed(() => {
if (isRocketMqCluster.value) { if (isRocketMqCluster.value) {
return topics.value.filter((topic) => isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic))).length; return topics.value.filter((topic) => isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic))).length;
@ -117,6 +158,10 @@ const userTopicCount = computed(() => {
return topics.value.filter((topic) => !topic.internal).length; return topics.value.filter((topic) => !topic.internal).length;
}); });
function topicRowSelected(topic: TopicInfo): boolean {
return selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace;
}
function topicTypeLabel(topic: TopicInfo): string { function topicTypeLabel(topic: TopicInfo): string {
if (isRocketMqCluster.value) { if (isRocketMqCluster.value) {
const type = resolveRocketMqMessageType(topic); const type = resolveRocketMqMessageType(topic);
@ -470,61 +515,68 @@ watch(newPartitions, () => {
</div> </div>
<div v-else class="topics-table"> <div v-else class="topics-table">
<table> <!-- Shared horizontal scroller keeps header/body columns aligned when the grid min-width exceeds the panel. -->
<thead> <div class="topics-table-hscroll" :style="{ '--topics-table-min-width': `${topicsTableMinWidthPx}px` }">
<tr> <div class="topics-table-header" :style="{ gridTemplateColumns: topicsGridTemplate }">
<th>{{ t("mqTopics.name") }}</th> <div class="topics-col">{{ t("mqTopics.name") }}</div>
<th v-if="showNamespaceColumn">{{ t("mqAdmin.namespace") }}</th> <div v-if="showNamespaceColumn" class="topics-col">{{ t("mqAdmin.namespace") }}</div>
<th>{{ t("mqTopics.type") }}</th> <div class="topics-col">{{ t("mqTopics.type") }}</div>
<th v-if="!isRocketMqCluster">{{ t("mqTopics.partitions") }}</th> <div v-if="!isRocketMqCluster" class="topics-col">{{ t("mqTopics.partitions") }}</div>
<th>{{ t("mqTopics.actions") }}</th> <div class="topics-col">{{ t("mqTopics.actions") }}</div>
</tr> </div>
</thead> <RecycleScroller class="topics-scroller" :items="virtualTopicRows" :item-size="TOPIC_ROW_HEIGHT" :buffer="200" key-field="id">
<tbody> <template #default="{ item: row }">
<tr v-for="topic in filteredTopics" :key="showNamespaceColumn ? `${topic.namespace ?? ''}:${topic.name}` : topic.name" :class="{ selected: selectedTopic?.name === topic.name && selectedTopic?.namespace === topic.namespace }" @click="selectTopic(topic)"> <div class="topics-row" :class="{ selected: topicRowSelected(row.topic) }" :style="{ gridTemplateColumns: topicsGridTemplate, height: `${TOPIC_ROW_HEIGHT}px` }" @click="selectTopic(row.topic)">
<td class="topic-name"> <div class="topics-col topic-name">
<div class="topic-name-cell"> <div class="topic-name-cell">
<span>{{ topic.shortName }}</span> <span class="topic-name-text" :title="row.topic.shortName">{{ row.topic.shortName }}</span>
<span v-if="!topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span> <span v-if="!row.topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
</div>
</div> </div>
</td> <div v-if="showNamespaceColumn" class="topics-col">{{ row.topic.namespace || "-" }}</div>
<td v-if="showNamespaceColumn">{{ topic.namespace || "-" }}</td> <div class="topics-col">
<td> <span class="badge" :class="topicTypeBadgeClass(row.topic)">
<span class="badge" :class="topicTypeBadgeClass(topic)"> {{ topicTypeLabel(row.topic) }}
{{ topicTypeLabel(topic) }} </span>
</span> </div>
</td> <div v-if="!isRocketMqCluster" class="topics-col">
<td v-if="!isRocketMqCluster"> <span v-if="row.topic.partitioned">{{ row.topic.partitions ? t("mqTopics.partitionCount", { count: row.topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span> <span v-else class="text-muted">-</span>
<span v-else class="text-muted">-</span> </div>
</td> <div class="topics-col actions" @click.stop>
<td class="actions" @click.stop> <template v-if="isRocketMqCluster">
<template v-if="isRocketMqCluster"> <button type="button" class="btn-sm" @click="openRocketMqDialog('status', row.topic)">{{ t("mqTopics.actionStatus") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('status', topic)">{{ t("mqTopics.actionStatus") }}</button> <button type="button" class="btn-sm" @click="openRocketMqDialog('route', row.topic)">{{ t("mqTopics.actionRoute") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('route', topic)">{{ t("mqTopics.actionRoute") }}</button> <button type="button" class="btn-sm" @click="openRocketMqDialog('consumers', row.topic)">{{ t("mqTopics.actionConsumers") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('consumers', topic)">{{ t("mqTopics.actionConsumers") }}</button> <button v-if="isDlqTopic(row.topic)" type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
<button v-if="isDlqTopic(topic)" class="btn-sm" @click="navigateToMessageQuery(topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button> <template v-else>
<template v-else> <button type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
<button class="btn-sm" @click="navigateToMessageQuery(topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button> <button type="button" class="btn-sm" :disabled="readOnly" @click="navigateToMessages(row.topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
<button class="btn-sm" :disabled="readOnly" @click="navigateToMessages(topic)">{{ t("mqRocketmq.actionSendMessage") }}</button> </template>
<button type="button" class="btn-sm" @click="openRocketMqDialog('config', row.topic)">{{ t("mqTopics.actionConfig") }}</button>
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('reset', row.topic)">{{ t("mqTopics.actionReset") }}</button>
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('skip', row.topic)">{{ t("mqTopics.actionSkip") }}</button>
<button type="button" class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(row.topic)" @click="handleDelete(row.topic)">{{ t("mqTopics.delete") }}</button>
</template> </template>
<button class="btn-sm" @click="openRocketMqDialog('config', topic)">{{ t("mqTopics.actionConfig") }}</button> <template v-else>
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('reset', topic)">{{ t("mqTopics.actionReset") }}</button> <button v-if="row.topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(row.topic)" type="button" @click="openPartitionsDialog(row.topic)" :disabled="readOnly || !row.topic.partitions" class="btn-sm">
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('skip', topic)">{{ t("mqTopics.actionSkip") }}</button> {{ t("mqTopics.adjustPartitions") }}
<button class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(topic)" @click="handleDelete(topic)">{{ t("mqTopics.delete") }}</button> </button>
</template> <button
<template v-else> type="button"
<button v-if="topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(topic)" @click="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm"> @click="handleDelete(row.topic)"
{{ t("mqTopics.adjustPartitions") }} :disabled="readOnly || isTopicProtected(row.topic) || (showNamespaceColumn && !row.topic.namespace)"
</button> :title="showNamespaceColumn && !row.topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined"
<button @click="handleDelete(topic)" :disabled="readOnly || isTopicProtected(topic) || (showNamespaceColumn && !topic.namespace)" :title="showNamespaceColumn && !topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined" class="btn-sm btn-danger"> class="btn-sm btn-danger"
{{ t("mqTopics.delete") }} >
</button> {{ t("mqTopics.delete") }}
</template> </button>
</td> </template>
</tr> </div>
</tbody> </div>
</table> </template>
</RecycleScroller>
</div>
</div> </div>
<!-- Create Dialog --> <!-- Create Dialog -->
@ -809,87 +861,94 @@ watch(newPartitions, () => {
.topics-table { .topics-table {
position: relative; position: relative;
flex: 1; flex: 1;
overflow: auto; min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--topics-surface); background: var(--topics-surface);
} }
.topics-table::before { .topics-table-hscroll {
content: ""; flex: 1;
position: sticky; min-height: 0;
top: 0; min-width: 0;
display: block; display: flex;
flex-direction: column;
overflow-x: auto;
overflow-y: hidden;
}
.topics-table-header,
.topics-row {
display: grid;
align-items: center;
column-gap: 8px;
padding: 0 12px;
min-width: var(--topics-table-min-width, 100%);
}
.topics-table-header {
flex: 0 0 auto;
height: 38px; height: 38px;
margin-bottom: -38px; /* Match RecycleScroller classic-scrollbar gutter so header/body columns stay aligned. */
background: var(--topics-header-bg); overflow: hidden;
z-index: 9; scrollbar-gutter: stable;
box-shadow:
0 1px 0 var(--topics-border),
0 2px 8px rgba(0, 0, 0, 0.05);
pointer-events: none;
}
table {
position: relative;
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
thead {
position: sticky;
top: 0;
background: var(--topics-header-bg);
z-index: 10;
}
th {
position: sticky;
top: 0;
z-index: 11;
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
background: var(--topics-header-bg); background: var(--topics-header-bg);
border-bottom: 1px solid var(--topics-border); border-bottom: 1px solid var(--topics-border);
background-clip: padding-box;
box-shadow: box-shadow:
0 1px 0 var(--topics-border), 0 1px 0 var(--topics-border),
0 2px 6px rgba(0, 0, 0, 0.04); 0 2px 6px rgba(0, 0, 0, 0.04);
z-index: 2;
} }
tbody tr { .topics-table-header .topics-col {
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
}
.topics-scroller {
flex: 1;
min-height: 0;
height: 100%;
min-width: var(--topics-table-min-width, 100%);
/* RecycleScroller owns overflow-y; reserve gutter for classic scrollbars (Windows). */
scrollbar-gutter: stable;
}
.topics-row {
width: 100%;
cursor: pointer; cursor: pointer;
transition: background 0.2s;
}
tbody tr:hover {
background: var(--color-hover);
}
tbody tr:hover td {
background: var(--color-hover);
}
tbody tr.selected {
background: var(--color-primary-alpha);
}
tbody tr.selected td {
background: var(--color-primary-alpha);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--topics-border-light); border-bottom: 1px solid var(--topics-border-light);
background: var(--topics-surface); background: var(--topics-surface);
box-sizing: border-box;
}
.topics-row:hover {
background: var(--color-hover);
}
.topics-row.selected {
background: var(--color-primary-alpha);
}
.topics-col {
min-width: 0;
font-size: 13px;
} }
.topic-name-cell { .topic-name-cell {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0;
}
.topic-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
} }
.topic-name { .topic-name {
@ -927,9 +986,11 @@ td {
.actions { .actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center; align-items: center;
min-width: 420px; justify-content: flex-start;
overflow-x: auto;
min-width: 0;
} }
.form-row-inline { .form-row-inline {

View File

@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, nextTick, type App } from "vue";
const backend = vi.hoisted(() => ({
mqListTopics: vi.fn(),
mqQueryMessagesByKey: vi.fn(),
mqQueryMessagesByTopic: vi.fn(),
mqViewMessage: vi.fn(),
mqPeekMessages: vi.fn(),
mqSendMessage: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => ({
mqListTopics: backend.mqListTopics,
mqQueryMessagesByKey: backend.mqQueryMessagesByKey,
mqQueryMessagesByTopic: backend.mqQueryMessagesByTopic,
mqViewMessage: backend.mqViewMessage,
mqPeekMessages: backend.mqPeekMessages,
mqSendMessage: backend.mqSendMessage,
}));
import MessageQueryPanel from "@/components/mq/MessageQueryPanel.vue";
const TOPIC = {
name: "Orders",
shortName: "Orders",
partitioned: true,
persistent: true,
messageType: "NORMAL",
};
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
await Promise.resolve();
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
function buttonByExactText(container: ParentNode, text: string): HTMLButtonElement {
const button = [...container.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === text);
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
async function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
}
async function mountPanel() {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(MessageQueryPanel, {
connectionId: "mq-1",
tenant: "_rocketmq",
namespace: "default",
topic: TOPIC,
mqSystemKind: "rocketmq",
embedded: true,
});
app.config.globalProperties.$t = (key: string) => key;
app.mount(root);
await flushUi();
return root;
}
beforeEach(() => {
backend.mqListTopics.mockReset();
backend.mqQueryMessagesByKey.mockReset();
backend.mqQueryMessagesByTopic.mockReset();
backend.mqViewMessage.mockReset();
backend.mqPeekMessages.mockReset();
backend.mqSendMessage.mockReset();
backend.mqListTopics.mockResolvedValue([TOPIC]);
backend.mqQueryMessagesByKey.mockResolvedValue({ messages: [] });
backend.mqQueryMessagesByTopic.mockResolvedValue({ messages: [] });
});
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
});
async function waitForQueryEnabled(panel: HTMLElement) {
for (let attempt = 0; attempt < 20; attempt++) {
const queryButton = buttonByExactText(panel, "mqMessages.query");
if (!queryButton.disabled) return queryButton;
await flushUi();
}
throw new Error("Query button stayed disabled (topic not selected)");
}
describe("MessageQueryPanel Key mode time validation", () => {
it("does not block Key query with a stale invalid Topic time range", async () => {
const panel = await mountPanel();
await expect.poll(() => backend.mqListTopics.mock.calls.length).toBeGreaterThan(0);
await flushUi();
const beginInput = panel.querySelector<HTMLInputElement>('input[type="datetime-local"]');
const endInputs = panel.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]');
if (!beginInput || endInputs.length < 2) throw new Error("Topic time inputs not found");
// Invalid range: begin after end (hidden after switching to Key).
await setInputValue(beginInput, "2026-08-03T18:00");
await setInputValue(endInputs[1], "2026-08-03T10:00");
buttonByExactText(panel, "mqMessages.queryTabKey").click();
await flushUi();
const keyInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.queryKeyPlaceholder"]');
if (!keyInput) throw new Error("Key input not found");
await setInputValue(keyInput, "order-1");
const queryButton = await waitForQueryEnabled(panel);
queryButton.click();
await flushUi();
await expect.poll(() => backend.mqQueryMessagesByKey.mock.calls.length).toBe(1);
expect(panel.textContent).not.toContain("mqMessages.invalidTimeRange");
expect(panel.textContent).not.toContain("mqMessages.endTimeMustBeAfterBegin");
expect(backend.mqQueryMessagesByTopic).not.toHaveBeenCalled();
const [, topicRef, key, begin, end, maxNum] = backend.mqQueryMessagesByKey.mock.calls[0];
expect(topicRef).toMatchObject({ topic: "Orders" });
expect(key).toBe("order-1");
expect(begin).toBe(0);
expect(typeof end).toBe("number");
expect(end).toBeGreaterThan(0);
expect(maxNum).toBe(64);
});
});

View File

@ -6052,6 +6052,8 @@ export default {
beginTime: "Begin time", beginTime: "Begin time",
endTime: "End time", endTime: "End time",
maxNum: "Max messages", maxNum: "Max messages",
invalidTimeRange: "Begin time or end time is invalid",
endTimeMustBeAfterBegin: "End time must be later than begin time",
query: "Query", query: "Query",
querying: "Querying...", querying: "Querying...",
messageOverview: "Overview", messageOverview: "Overview",

View File

@ -5910,6 +5910,8 @@ export default withEnglishFallback({
beginTime: "Hora de inicio", beginTime: "Hora de inicio",
endTime: "Hora de fin", endTime: "Hora de fin",
maxNum: "Número máximo", maxNum: "Número máximo",
invalidTimeRange: "Hora de inicio o fin no válida",
endTimeMustBeAfterBegin: "La hora de fin debe ser posterior a la hora de inicio",
query: "Consultar", query: "Consultar",
querying: "Consultando...", querying: "Consultando...",
messageOverview: "Información básica", messageOverview: "Información básica",

View File

@ -5910,6 +5910,8 @@ export default withEnglishFallback({
beginTime: "Ora inizio", beginTime: "Ora inizio",
endTime: "Ora fine", endTime: "Ora fine",
maxNum: "Numero massimo", maxNum: "Numero massimo",
invalidTimeRange: "Ora di inizio o fine non valida",
endTimeMustBeAfterBegin: "L'ora di fine deve essere successiva all'ora di inizio",
query: "Cerca", query: "Cerca",
querying: "Ricerca in corso...", querying: "Ricerca in corso...",
messageOverview: "Informazioni di base", messageOverview: "Informazioni di base",

View File

@ -5965,6 +5965,8 @@ export default withEnglishFallback({
beginTime: "開始時間", beginTime: "開始時間",
endTime: "終了時間", endTime: "終了時間",
maxNum: "最大件数", maxNum: "最大件数",
invalidTimeRange: "開始時刻または終了時刻が無効です",
endTimeMustBeAfterBegin: "終了時刻は開始時刻より後である必要があります",
query: "検索", query: "検索",
querying: "検索中...", querying: "検索中...",
messageOverview: "基本情報", messageOverview: "基本情報",

View File

@ -5644,6 +5644,8 @@ export default withEnglishFallback({
beginTime: "시작 시간", beginTime: "시작 시간",
endTime: "종료 시간", endTime: "종료 시간",
maxNum: "최대 메시지 수", maxNum: "최대 메시지 수",
invalidTimeRange: "시작 시간 또는 종료 시간이 유효하지 않습니다",
endTimeMustBeAfterBegin: "종료 시간은 시작 시간보다 늦어야 합니다",
query: "쿼리", query: "쿼리",
querying: "쿼리 중...", querying: "쿼리 중...",
messageOverview: "개요", messageOverview: "개요",

View File

@ -5912,6 +5912,8 @@ export default withEnglishFallback({
beginTime: "Hora inicial", beginTime: "Hora inicial",
endTime: "Hora final", endTime: "Hora final",
maxNum: "Número máximo", maxNum: "Número máximo",
invalidTimeRange: "Hora inicial ou final inválida",
endTimeMustBeAfterBegin: "A hora final deve ser posterior à hora inicial",
query: "Pesquisar", query: "Pesquisar",
querying: "Pesquisando...", querying: "Pesquisando...",
messageOverview: "Informações básicas", messageOverview: "Informações básicas",

View File

@ -6051,6 +6051,8 @@ export default withEnglishFallback({
beginTime: "开始时间", beginTime: "开始时间",
endTime: "结束时间", endTime: "结束时间",
maxNum: "最大条数", maxNum: "最大条数",
invalidTimeRange: "开始时间或结束时间无效",
endTimeMustBeAfterBegin: "结束时间必须晚于开始时间",
query: "查询", query: "查询",
querying: "查询中...", querying: "查询中...",
messageOverview: "基本信息", messageOverview: "基本信息",

View File

@ -5905,6 +5905,8 @@ export default withEnglishFallback({
beginTime: "開始時間", beginTime: "開始時間",
endTime: "結束時間", endTime: "結束時間",
maxNum: "最大條數", maxNum: "最大條數",
invalidTimeRange: "開始時間或結束時間無效",
endTimeMustBeAfterBegin: "結束時間必須晚於開始時間",
query: "查詢", query: "查詢",
querying: "查詢中...", querying: "查詢中...",
messageOverview: "基本資訊", messageOverview: "基本資訊",

View File

@ -2170,23 +2170,37 @@ impl AppState {
// connection_id is recognized as valid. // connection_id is recognized as valid.
let mqc = self.mq_admin_config_for_connection(connection_id, &config).await?; let mqc = self.mq_admin_config_for_connection(connection_id, &config).await?;
let agent_launch = crate::mq::service::resolve_mq_agent_launch_spec(&mqc, self); let agent_launch = crate::mq::service::resolve_mq_agent_launch_spec(&mqc, self);
let adapter = match self.mq_registry.get_or_build_config(connection_id, mqc, agent_launch).await { // Temporary "__test_*" probes must not retain agents in the registry.
Ok(adapter) => adapter, // reconnect fast-path caching only applies to durable connection ids;
Err(err) => { // drain_connection_pools no longer drops MQ adapters (reconnect reuse).
if connection_id.starts_with("__test_") {
let adapter = self.mq_registry.build_transient_config(mqc, agent_launch).await?;
adapter.test_connection().await?;
if let Err(err) = self.ensure_current_connection_attempt(connection_id, connection_attempt).await {
self.reset_connection_transport_for_config(connection_id, &db_config).await;
return Err(err);
}
// adapter drops here and kills agent-backed processes (RocketMQ/Kafka/RabbitMQ).
PoolKind::MessageQueue
} else {
let build = match self.mq_registry.get_or_build_config(connection_id, mqc, agent_launch).await {
Ok(build) => build,
Err(err) => {
self.mq_registry.drop_connection(connection_id).await;
return Err(err);
}
};
if let Err(err) = crate::mq::validate_mq_adapter_after_build(&build).await {
self.mq_registry.drop_connection(connection_id).await; self.mq_registry.drop_connection(connection_id).await;
return Err(err); return Err(err);
} }
}; if let Err(err) = self.ensure_current_connection_attempt(connection_id, connection_attempt).await {
if let Err(err) = adapter.test_connection().await { self.mq_registry.drop_connection(connection_id).await;
self.mq_registry.drop_connection(connection_id).await; self.reset_connection_transport_for_config(connection_id, &db_config).await;
return Err(err); return Err(err);
}
PoolKind::MessageQueue
} }
if let Err(err) = self.ensure_current_connection_attempt(connection_id, connection_attempt).await {
self.mq_registry.drop_connection(connection_id).await;
self.reset_connection_transport_for_config(connection_id, &db_config).await;
return Err(err);
}
PoolKind::MessageQueue
} }
#[cfg(not(feature = "mq-admin"))] #[cfg(not(feature = "mq-admin"))]
DatabaseType::MessageQueue => { DatabaseType::MessageQueue => {
@ -3943,9 +3957,6 @@ impl AppState {
} }
} }
drop(conns); drop(conns);
// Also drop the MQ admin adapter if this is an MQ connection.
#[cfg(feature = "mq-admin")]
self.mq_registry.drop_connection(connection_id).await;
removed removed
} }

View File

@ -52,13 +52,46 @@ const ROCKETMQ_CAPABILITIES: MqCapabilities = MqCapabilities {
supports_cluster_monitoring: false, supports_cluster_monitoring: false,
}; };
const TOPIC_LIST_PAGE_SIZE: u32 = 200; /// Prefer one RPC for the full topic catalog.
///
/// Use a large *positive* limit (not `0`): older agents coerce `limit <= 0` back to 200,
/// which truncates catalogs (e.g. 338 topics → 200 rows → ~167 after type filters).
/// Current agents treat `limit <= 0` as "all"; a large positive limit returns the same
/// full page via normal pagination math without that version skew.
const TOPIC_LIST_FETCH_LIMIT: i32 = i32::MAX;
/// Fallback page size when an agent still returns a truncated first page (`topics.len() < total`).
const TOPIC_LIST_FALLBACK_PAGE_SIZE: i32 = 200;
pub struct RocketMqAdmin { pub struct RocketMqAdmin {
client: Arc<Mutex<AgentDriverClient>>, client: Arc<Mutex<AgentDriverClient>>,
config: MqAdminConfig, config: MqAdminConfig,
} }
fn cluster_info_from_agent_result(result: &serde_json::Value) -> MqClusterInfo {
let cluster_id = result.get("clusterId").and_then(|v| v.as_str()).map(String::from);
let brokers = result.get("brokers").cloned().unwrap_or(serde_json::json!([]));
// When the broker has no authorizer configured, disable permissions in the UI
// so the frontend hides the tab instead of showing raw errors.
let acl_enabled = result.get("aclEnabled").and_then(|v| v.as_bool()).unwrap_or(true);
let mut caps = ROCKETMQ_CAPABILITIES;
if !acl_enabled {
caps.supports_permissions = false;
}
MqClusterInfo {
system_kind: MqSystemKind::RocketMq,
server_version: None,
resolved_profile: "rocketmq-agent".to_string(),
version_detection: "agent".to_string(),
capabilities: caps,
extra: serde_json::json!({
"clusterId": cluster_id,
"brokers": brokers,
}),
}
}
impl RocketMqAdmin { impl RocketMqAdmin {
/// Spawn the RocketMQ Java agent, perform handshake, and connect. /// Spawn the RocketMQ Java agent, perform handshake, and connect.
pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> { pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> {
@ -104,33 +137,16 @@ impl MessageQueueAdmin for RocketMqAdmin {
MqSystemKind::RocketMq MqSystemKind::RocketMq
} }
fn build_includes_connect_test(&self) -> bool {
true
}
async fn test_connection(&self) -> Result<MqClusterInfo, String> { async fn test_connection(&self) -> Result<MqClusterInfo, String> {
let conn_params = build_connection_params(&self.config); let conn_params = build_connection_params(&self.config);
let result: serde_json::Value = let result: serde_json::Value =
self.call("test_connection", serde_json::json!({ "connection": conn_params })).await?; self.call("test_connection", serde_json::json!({ "connection": conn_params })).await?;
let cluster_id = result.get("clusterId").and_then(|v| v.as_str()).map(String::from); Ok(cluster_info_from_agent_result(&result))
let brokers = result.get("brokers").cloned().unwrap_or(serde_json::json!([]));
// When the broker has no authorizer configured, disable permissions in the UI
// so the frontend hides the tab instead of showing raw errors.
let acl_enabled = result.get("aclEnabled").and_then(|v| v.as_bool()).unwrap_or(true);
let mut caps = ROCKETMQ_CAPABILITIES;
if !acl_enabled {
caps.supports_permissions = false;
}
Ok(MqClusterInfo {
system_kind: MqSystemKind::RocketMq,
server_version: None,
resolved_profile: "rocketmq-agent".to_string(),
version_detection: "agent".to_string(),
capabilities: caps,
extra: serde_json::json!({
"clusterId": cluster_id,
"brokers": brokers,
}),
})
} }
// ---- Tenants (not supported by RocketMQ) ---- // ---- Tenants (not supported by RocketMQ) ----
@ -176,35 +192,40 @@ impl MessageQueueAdmin for RocketMqAdmin {
// ---- Topics ---- // ---- Topics ----
async fn list_topics(&self, _ns: &NamespaceRef, _opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String> { async fn list_topics(&self, _ns: &NamespaceRef, _opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String> {
let mut all = Vec::new(); // Prefer a single RPC so the agent builds the catalog once. If the agent still
let mut offset: u32 = 0; // truncates (old coerce-to-200 behavior), page the remainder using `total`.
let result: serde_json::Value = self
.call(
"mq_list_topics",
serde_json::json!({
"keyword": "",
"limit": TOPIC_LIST_FETCH_LIMIT,
"offset": 0,
}),
)
.await?;
loop { let mut all = topic_infos_from_agent_list_response(&result);
let result: serde_json::Value = self let total = agent_list_total(&result, all.len());
let mut offset = all.len();
while (offset as u64) < total {
let page: serde_json::Value = self
.call( .call(
"mq_list_topics", "mq_list_topics",
serde_json::json!({ serde_json::json!({
"keyword": "", "keyword": "",
"limit": TOPIC_LIST_PAGE_SIZE, "limit": TOPIC_LIST_FALLBACK_PAGE_SIZE,
"offset": offset, "offset": offset,
}), }),
) )
.await?; .await?;
let batch = topic_infos_from_agent_list_response(&page);
let topics = result.get("topics").and_then(|v| v.as_array()).cloned().unwrap_or_default(); if batch.is_empty() {
let page_len = topics.len();
for topic in topics {
all.push(topic_info_from_agent_value(&topic));
}
let total = result.get("total").and_then(|v| v.as_u64()).unwrap_or(offset as u64 + page_len as u64);
let fetch_next = topic_list_should_fetch_next(offset, page_len, total);
offset = offset.saturating_add(page_len as u32);
if !fetch_next {
break; break;
} }
offset = offset.saturating_add(batch.len());
all.extend(batch);
} }
Ok(all) Ok(all)
} }
@ -735,28 +756,13 @@ fn topic_info_from_agent_value(t: &serde_json::Value) -> TopicInfo {
} }
} }
/// Whether another Agent topic list page should be requested after consuming `page_len` rows. fn topic_infos_from_agent_list_response(response: &serde_json::Value) -> Vec<TopicInfo> {
fn topic_list_should_fetch_next(offset: u32, page_len: usize, total: u64) -> bool { response.get("topics").and_then(|v| v.as_array()).into_iter().flatten().map(topic_info_from_agent_value).collect()
page_len > 0 && u64::from(offset) + (page_len as u64) < total
} }
#[cfg(test)] /// Prefer agent-reported `total`; fall back to the page length when absent.
fn topic_infos_from_agent_pages(pages: &[serde_json::Value]) -> Vec<TopicInfo> { fn agent_list_total(response: &serde_json::Value, page_len: usize) -> u64 {
let mut all = Vec::new(); response.get("total").and_then(|v| v.as_u64()).unwrap_or(page_len as u64)
let mut offset: u32 = 0;
for page in pages {
let topics = page.get("topics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let page_len = topics.len();
for topic in topics {
all.push(topic_info_from_agent_value(&topic));
}
let total = page.get("total").and_then(|v| v.as_u64()).unwrap_or(offset as u64 + page_len as u64);
offset = offset.saturating_add(page_len as u32);
if page_len == 0 || offset as u64 >= total {
break;
}
}
all
} }
/// Extract RocketMQ NameServer address from MqAdminConfig.extra. /// Extract RocketMQ NameServer address from MqAdminConfig.extra.
@ -1043,40 +1049,28 @@ mod tests {
} }
#[test] #[test]
fn topic_list_should_fetch_next_when_more_rows_remain() { fn topic_infos_from_agent_list_response_parses_full_catalog() {
assert!(!topic_list_should_fetch_next(200, 0, 201)); let topics: Vec<serde_json::Value> = (0..341)
assert!(topic_list_should_fetch_next(0, 200, 201)); .map(|i| serde_json::json!({ "name": format!("topic-{i}"), "partitions": 4, "messageType": "UNSPECIFIED" }))
assert!(!topic_list_should_fetch_next(0, 200, 200)); .collect();
assert!(topic_list_should_fetch_next(200, 1, 450)); let response = serde_json::json!({ "topics": topics, "total": 341, "offset": 0, "limit": i32::MAX });
assert!(!topic_list_should_fetch_next(400, 50, 450)); let parsed = topic_infos_from_agent_list_response(&response);
assert_eq!(parsed.len(), 341);
assert_eq!(agent_list_total(&response, parsed.len()), 341);
assert_eq!(parsed.first().map(|t| t.name.as_str()), Some("topic-0"));
assert_eq!(parsed.last().map(|t| t.name.as_str()), Some("topic-340"));
assert_eq!(parsed[0].message_type.as_deref(), Some("UNSPECIFIED"));
} }
#[test] #[test]
fn topic_infos_from_agent_pages_merges_200_201_and_multi_page_totals() { fn agent_list_total_detects_truncated_first_page() {
let page1: Vec<serde_json::Value> = let page: Vec<serde_json::Value> =
(0..200).map(|i| serde_json::json!({ "name": format!("topic-{i}"), "partitions": 4 })).collect(); (0..200).map(|i| serde_json::json!({ "name": format!("topic-{i}"), "partitions": 4 })).collect();
let page2_201 = serde_json::json!({ "name": "topic-200", "partitions": 4 }); let response = serde_json::json!({ "topics": page, "total": 338, "offset": 0, "limit": 200 });
let response_201_first = serde_json::json!({ "topics": page1, "total": 201, "offset": 0, "limit": 200 }); let parsed = topic_infos_from_agent_list_response(&response);
let response_201_second = assert_eq!(parsed.len(), 200);
serde_json::json!({ "topics": [page2_201], "total": 201, "offset": 200, "limit": 200 }); assert_eq!(agent_list_total(&response, parsed.len()), 338);
let merged_201 = topic_infos_from_agent_pages(&[response_201_first, response_201_second]); assert!((parsed.len() as u64) < agent_list_total(&response, parsed.len()));
assert_eq!(merged_201.len(), 201);
assert_eq!(merged_201.last().map(|t| t.name.as_str()), Some("topic-200"));
let page_a: Vec<serde_json::Value> =
(0..200).map(|i| serde_json::json!({ "name": format!("p-{i}"), "partitions": 1 })).collect();
let page_b: Vec<serde_json::Value> =
(200..400).map(|i| serde_json::json!({ "name": format!("p-{i}"), "partitions": 1 })).collect();
let page_c: Vec<serde_json::Value> =
(400..450).map(|i| serde_json::json!({ "name": format!("p-{i}"), "partitions": 1 })).collect();
let merged_450 = topic_infos_from_agent_pages(&[
serde_json::json!({ "topics": page_a, "total": 450 }),
serde_json::json!({ "topics": page_b, "total": 450 }),
serde_json::json!({ "topics": page_c, "total": 450 }),
]);
assert_eq!(merged_450.len(), 450);
assert_eq!(merged_450.first().map(|t| t.name.as_str()), Some("p-0"));
assert_eq!(merged_450.last().map(|t| t.name.as_str()), Some("p-449"));
} }
#[test] #[test]
@ -1102,4 +1096,18 @@ mod tests {
assert_eq!(peeked.properties.get("tag").map(String::as_str), Some("cs-pt-dlq-test")); assert_eq!(peeked.properties.get("tag").map(String::as_str), Some("cs-pt-dlq-test"));
assert_eq!(peeked.publish_time.as_deref(), Some("1710000000000")); assert_eq!(peeked.publish_time.as_deref(), Some("1710000000000"));
} }
#[test]
fn cluster_info_from_agent_result_parses_acl_and_brokers() {
let result = serde_json::json!({
"ok": true,
"clusterId": "DefaultCluster",
"brokers": [{"brokerName": "broker-a"}],
"aclEnabled": false
});
let info = super::cluster_info_from_agent_result(&result);
assert_eq!(info.system_kind, MqSystemKind::RocketMq);
assert!(!info.capabilities.supports_permissions);
assert_eq!(info.extra.get("clusterId").and_then(|v| v.as_str()), Some("DefaultCluster"));
}
} }

View File

@ -56,6 +56,13 @@ struct CachedMqAdmin {
adapter: Arc<dyn MessageQueueAdmin>, adapter: Arc<dyn MessageQueueAdmin>,
} }
/// Result of resolving an MQ admin adapter for a connection attempt.
pub struct MqBuildResult {
pub adapter: Arc<dyn MessageQueueAdmin>,
/// True when an existing cached adapter was reused (reconnect fast path).
pub was_cached: bool,
}
impl MqAdminRegistry { impl MqAdminRegistry {
pub fn new() -> Self { pub fn new() -> Self {
Self { instances: RwLock::new(HashMap::new()), build_locks: RwLock::new(HashMap::new()) } Self { instances: RwLock::new(HashMap::new()), build_locks: RwLock::new(HashMap::new()) }
@ -63,7 +70,7 @@ impl MqAdminRegistry {
/// Return the cached adapter for this connection, building it from the /// Return the cached adapter for this connection, building it from the
/// connection's `external_config` if not already present. /// connection's `external_config` if not already present.
pub async fn get_or_build(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> { pub async fn get_or_build(&self, cfg: &ConnectionConfig) -> Result<MqBuildResult, String> {
let mqc = MqAdminConfig::from_connection(cfg)?; let mqc = MqAdminConfig::from_connection(cfg)?;
self.get_or_build_config(&cfg.id, mqc, None).await self.get_or_build_config(&cfg.id, mqc, None).await
} }
@ -73,13 +80,13 @@ impl MqAdminRegistry {
connection_id: &str, connection_id: &str,
mqc: MqAdminConfig, mqc: MqAdminConfig,
agent_launch: Option<AgentLaunchSpec>, agent_launch: Option<AgentLaunchSpec>,
) -> Result<Arc<dyn MessageQueueAdmin>, String> { ) -> Result<MqBuildResult, String> {
let fingerprint = adapter_fingerprint(&mqc, agent_launch.as_ref()); let fingerprint = adapter_fingerprint(&mqc, agent_launch.as_ref());
// Fast path: return the cached adapter. // Fast path: return the cached adapter.
if let Some(entry) = self.instances.read().await.get(connection_id) { if let Some(entry) = self.instances.read().await.get(connection_id) {
if entry.fingerprint == fingerprint { if entry.fingerprint == fingerprint {
return Ok(entry.adapter.clone()); return Ok(MqBuildResult { adapter: entry.adapter.clone(), was_cached: true });
} }
} }
@ -94,16 +101,19 @@ impl MqAdminRegistry {
// Another task may have built it while we were waiting for the lock. // Another task may have built it while we were waiting for the lock.
if let Some(entry) = self.instances.read().await.get(connection_id) { if let Some(entry) = self.instances.read().await.get(connection_id) {
if entry.fingerprint == fingerprint { if entry.fingerprint == fingerprint {
return Ok(entry.adapter.clone()); return Ok(MqBuildResult { adapter: entry.adapter.clone(), was_cached: true });
} }
} }
// Config changed — drop the stale adapter so its agent process is released.
self.instances.write().await.remove(connection_id);
let adapter = build_adapter(mqc, agent_launch).await?; let adapter = build_adapter(mqc, agent_launch).await?;
self.instances self.instances
.write() .write()
.await .await
.insert(connection_id.to_string(), CachedMqAdmin { fingerprint, adapter: adapter.clone() }); .insert(connection_id.to_string(), CachedMqAdmin { fingerprint, adapter: adapter.clone() });
Ok(adapter) Ok(MqBuildResult { adapter, was_cached: false })
} }
/// Drop the cached adapter for a connection (called on disconnect). /// Drop the cached adapter for a connection (called on disconnect).
@ -112,6 +122,16 @@ impl MqAdminRegistry {
self.build_locks.write().await.remove(connection_id); self.build_locks.write().await.remove(connection_id);
} }
/// Whether an adapter is currently cached for this connection id.
pub async fn has_cached_connection(&self, connection_id: &str) -> bool {
self.instances.read().await.contains_key(connection_id)
}
/// Connection ids currently holding a cached MQ adapter (for cleanup assertions).
pub async fn cached_connection_ids(&self) -> Vec<String> {
self.instances.read().await.keys().cloned().collect()
}
/// Build a fresh adapter without caching it — used for connection tests /// Build a fresh adapter without caching it — used for connection tests
/// where we don't want to retain state. /// where we don't want to retain state.
pub async fn build_transient(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> { pub async fn build_transient(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
@ -128,6 +148,15 @@ impl MqAdminRegistry {
} }
} }
/// Validate connectivity after resolving an MQ adapter. Skips an immediate probe when
/// the adapter was just built and its connect path already verified the cluster.
pub async fn validate_mq_adapter_after_build(build: &MqBuildResult) -> Result<(), String> {
if build.was_cached || !build.adapter.build_includes_connect_test() {
build.adapter.test_connection().await?;
}
Ok(())
}
fn adapter_fingerprint(mqc: &MqAdminConfig, agent_launch: Option<&AgentLaunchSpec>) -> u64 { fn adapter_fingerprint(mqc: &MqAdminConfig, agent_launch: Option<&AgentLaunchSpec>) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new(); let mut hasher = std::collections::hash_map::DefaultHasher::new();
format!("{mqc:?}").hash(&mut hasher); format!("{mqc:?}").hash(&mut hasher);

View File

@ -18,6 +18,13 @@ pub trait MessageQueueAdmin: Send + Sync {
/// Connectivity test; returns cluster/version info. /// Connectivity test; returns cluster/version info.
async fn test_connection(&self) -> Result<MqClusterInfo, String>; async fn test_connection(&self) -> Result<MqClusterInfo, String>;
/// When true, the adapter's build path already validated connectivity (e.g.
/// RocketMQ agent `connect` RPC). Callers may skip an immediate follow-up
/// `test_connection` on first build, but should still test on cache reuse.
fn build_includes_connect_test(&self) -> bool {
false
}
// ---- Tenants ---- // ---- Tenants ----
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String>; async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String>;
async fn get_tenant(&self, name: &str) -> Result<TenantInfo, String>; async fn get_tenant(&self, name: &str) -> Result<TenantInfo, String>;

View File

@ -22,14 +22,14 @@ pub async fn mq_test_connection_core(state: &AppState, conn_id: &str) -> Result<
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?; let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?; let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
let agent_launch = resolve_mq_agent_launch_spec(&mqc, state); let agent_launch = resolve_mq_agent_launch_spec(&mqc, state);
let adapter = match state.mq_registry.get_or_build_config(conn_id, mqc, agent_launch).await { let build = match state.mq_registry.get_or_build_config(conn_id, mqc, agent_launch).await {
Ok(adapter) => adapter, Ok(build) => build,
Err(err) => { Err(err) => {
state.mq_registry.drop_connection(conn_id).await; state.mq_registry.drop_connection(conn_id).await;
return Err(err); return Err(err);
} }
}; };
match adapter.test_connection().await { match build.adapter.test_connection().await {
Ok(info) => Ok(info), Ok(info) => Ok(info),
Err(err) => { Err(err) => {
state.mq_registry.drop_connection(conn_id).await; state.mq_registry.drop_connection(conn_id).await;
@ -801,7 +801,7 @@ async fn get_adapter(
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?; let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?; let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
let agent_launch = resolve_mq_agent_launch_spec(&mqc, state); let agent_launch = resolve_mq_agent_launch_spec(&mqc, state);
state.mq_registry.get_or_build_config(conn_id, mqc, agent_launch).await state.mq_registry.get_or_build_config(conn_id, mqc, agent_launch).await.map(|build| build.adapter)
} }
/// Resolve the MQ agent launch spec for agent-backed systems (Kafka, RocketMQ, RabbitMQ). /// Resolve the MQ agent launch spec for agent-backed systems (Kafka, RocketMQ, RabbitMQ).

View File

@ -181,6 +181,10 @@ async fn run_temporary_connection_test(
}; };
app.remove_connection_pools(&temp_id).await; app.remove_connection_pools(&temp_id).await;
// Pool drain intentionally keeps durable MQ adapters for reconnect reuse; temporary
// probes must still release any registry entry if a cached path was used.
#[cfg(feature = "mq-admin")]
app.mq_registry.drop_connection(&temp_id).await;
app.reset_connection_transport_for_config(&temp_id, &config).await; app.reset_connection_transport_for_config(&temp_id, &config).await;
app.configs.write().await.remove(&temp_id); app.configs.write().await.remove(&temp_id);
@ -669,6 +673,30 @@ mod tests {
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
#[cfg(feature = "mq-admin")]
#[tokio::test]
async fn mq_connection_test_does_not_retain_temporary_adapter() {
let (state, dir) = test_web_state().await;
let admin_url = spawn_pulsar_clusters_server().await;
let config = mq_config("pulsar-probe", &admin_url);
let result = test_connection(State(state.clone()), Json(ConnectRequest { config, client_attempt: None }))
.await
.unwrap_or_else(|error| panic!("{}", error.message));
assert_eq!(result.0, "Connection successful");
assert!(state.app.configs.read().await.keys().all(|key| !key.starts_with("__test_")));
assert!(state.app.connections.read().await.keys().all(|key| !key.starts_with("__test_")));
let cached = state.app.mq_registry.cached_connection_ids().await;
assert!(
cached.iter().all(|id| !id.starts_with("__test_")),
"temporary MQ connection tests must not retain registry adapters: {cached:?}"
);
assert!(!state.app.mq_registry.has_cached_connection("pulsar-probe").await);
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test] #[tokio::test]
async fn invalid_persistent_sqlite_attachments_do_not_replace_live_web_state() { async fn invalid_persistent_sqlite_attachments_do_not_replace_live_web_state() {
let (state, dir) = test_web_state().await; let (state, dir) = test_web_state().await;
@ -898,7 +926,7 @@ mod tests {
let initial = mq_config("mq-conn", "http://127.0.0.1:8080"); let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
state.app.configs.write().await.insert(initial.id.clone(), initial.clone()); state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue); state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap(); let first = state.app.mq_registry.get_or_build(&initial).await.unwrap().adapter;
let updated = mq_config("mq-conn", "http://127.0.0.1:8081"); let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
let result = let result =
@ -918,7 +946,7 @@ mod tests {
.map(str::to_string); .map(str::to_string);
assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081")); assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081"));
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap(); let second = state.app.mq_registry.get_or_build(&updated).await.unwrap().adapter;
assert!(!Arc::ptr_eq(&first, &second)); assert!(!Arc::ptr_eq(&first, &second));
assert!(!state.app.connections.read().await.contains_key(&initial.id)); assert!(!state.app.connections.read().await.contains_key(&initial.id));
@ -932,7 +960,7 @@ mod tests {
let initial = mq_config("mq-conn", "http://127.0.0.1:8080"); let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
state.app.configs.write().await.insert(initial.id.clone(), initial.clone()); state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue); state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap(); let first = state.app.mq_registry.get_or_build(&initial).await.unwrap().adapter;
let updated = mq_config("mq-conn", &spawn_pulsar_clusters_server().await); let updated = mq_config("mq-conn", &spawn_pulsar_clusters_server().await);
let result = let result =
@ -940,7 +968,7 @@ mod tests {
.await; .await;
assert!(result.is_ok()); assert!(result.is_ok());
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap(); let second = state.app.mq_registry.get_or_build(&updated).await.unwrap().adapter;
assert!(!Arc::ptr_eq(&first, &second)); assert!(!Arc::ptr_eq(&first, &second));
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
@ -982,7 +1010,7 @@ mod tests {
configs.insert(kept.id.clone(), kept.clone()); configs.insert(kept.id.clone(), kept.clone());
configs.insert(removed.id.clone(), removed.clone()); configs.insert(removed.id.clone(), removed.clone());
} }
let stale = state.app.mq_registry.get_or_build(&removed).await.unwrap(); let stale = state.app.mq_registry.get_or_build(&removed).await.unwrap().adapter;
let result = let result =
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await; save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
@ -993,7 +1021,7 @@ mod tests {
assert!(!configs.contains_key("removed-mq")); assert!(!configs.contains_key("removed-mq"));
drop(configs); drop(configs);
let rebuilt = state.app.mq_registry.get_or_build(&removed).await.unwrap(); let rebuilt = state.app.mq_registry.get_or_build(&removed).await.unwrap().adapter;
assert!(!Arc::ptr_eq(&stale, &rebuilt)); assert!(!Arc::ptr_eq(&stale, &rebuilt));
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
@ -1120,7 +1148,7 @@ mod tests {
let config = mq_config("mq-conn", "http://127.0.0.1:8080"); let config = mq_config("mq-conn", "http://127.0.0.1:8080");
state.app.configs.write().await.insert(config.id.clone(), config.clone()); state.app.configs.write().await.insert(config.id.clone(), config.clone());
state.app.connections.write().await.insert(config.id.clone(), PoolKind::MessageQueue); state.app.connections.write().await.insert(config.id.clone(), PoolKind::MessageQueue);
let first = state.app.mq_registry.get_or_build(&config).await.unwrap(); let first = state.app.mq_registry.get_or_build(&config).await.unwrap().adapter;
let result = disconnect_db( let result = disconnect_db(
State(state.clone()), State(state.clone()),
@ -1130,7 +1158,7 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
assert!(!state.app.connections.read().await.contains_key(&config.id)); assert!(!state.app.connections.read().await.contains_key(&config.id));
let second = state.app.mq_registry.get_or_build(&config).await.unwrap(); let second = state.app.mq_registry.get_or_build(&config).await.unwrap().adapter;
assert!(!Arc::ptr_eq(&first, &second)); assert!(!Arc::ptr_eq(&first, &second));
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);

View File

@ -20,7 +20,7 @@ assertMatches("crates/dbx-web/Cargo.toml", /\[features\][\s\S]*default\s*=\s*\[[
assertIncludes("apps/desktop/src/components/layout/ContentArea.vue", "MqAdminConsole", "Main content area must import and render the MQ admin console."); assertIncludes("apps/desktop/src/components/layout/ContentArea.vue", "MqAdminConsole", "Main content area must import and render the MQ admin console.");
assertIncludes("apps/desktop/src/components/layout/ContentArea.vue", "activeTab.mode === 'mq'", "Main content area must have an mq mode render branch."); assertIncludes("apps/desktop/src/components/layout/ContentArea.vue", "activeTab.mode === 'mq'", "Main content area must have an mq mode render branch.");
assertIncludes("apps/desktop/src/components/sidebar/TreeItem.vue", '"mq"', "Sidebar connection handling must be aware of MQ connections."); assertIncludes("apps/desktop/src/components/sidebar/TreeItem.vue", "mq-tenant", "Sidebar tree items must render MQ tenant nodes.");
assertIncludes("apps/desktop/src/stores/queryStore.ts", "openMqAdmin", "Query store must expose an MQ admin tab opener."); assertIncludes("apps/desktop/src/stores/queryStore.ts", "openMqAdmin", "Query store must expose an MQ admin tab opener.");
const manifest = JSON.parse(read("crates/dbx-core/assets/database-drivers.manifest.json")); const manifest = JSON.parse(read("crates/dbx-core/assets/database-drivers.manifest.json"));
@ -80,6 +80,16 @@ for (const panel of ["PoliciesPanel.vue", "PermissionsPanel.vue", "RawApiPanel.v
for (const panel of ["TenantsPanel.vue", "NamespacesPanel.vue", "TopicsPanel.vue", "SubscriptionsPanel.vue"]) { for (const panel of ["TenantsPanel.vue", "NamespacesPanel.vue", "TopicsPanel.vue", "SubscriptionsPanel.vue"]) {
assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`); assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`);
} }
assertIncludes(
"apps/desktop/src/components/mq/TopicsPanel.vue",
"topics-table-hscroll",
"TopicsPanel must keep a shared horizontal scroller so wide RocketMQ action columns stay reachable.",
);
assertIncludes(
"apps/desktop/src/components/mq/TopicsPanel.vue",
"overflow-x: auto",
"TopicsPanel horizontal scroller must allow overflow-x.",
);
for (const panel of ["ExchangesPanel.vue", "SendMessagePanel.vue", "rabbitmq/RabbitMqClientsPanel.vue", "ProducerConsumerPanel.vue"]) { for (const panel of ["ExchangesPanel.vue", "SendMessagePanel.vue", "rabbitmq/RabbitMqClientsPanel.vue", "ProducerConsumerPanel.vue"]) {
assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`); assertIncludes(`apps/desktop/src/components/mq/${panel}`, "readOnly", `${panel} must disable mutating actions in read-only mode.`);
} }

View File

@ -485,7 +485,7 @@ mod tests {
let initial = mq_config("mq-conn", "http://127.0.0.1:8080"); let initial = mq_config("mq-conn", "http://127.0.0.1:8080");
state.configs.write().await.insert(initial.id.clone(), initial.clone()); state.configs.write().await.insert(initial.id.clone(), initial.clone());
state.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue); state.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
let first = state.mq_registry.get_or_build(&initial).await.unwrap(); let first = state.mq_registry.get_or_build(&initial).await.unwrap().adapter;
let updated = mq_config("mq-conn", "http://127.0.0.1:8081"); let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
save_connection_configs(&state, std::slice::from_ref(&updated)).await.unwrap(); save_connection_configs(&state, std::slice::from_ref(&updated)).await.unwrap();
@ -501,7 +501,7 @@ mod tests {
.map(str::to_string); .map(str::to_string);
assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081")); assert_eq!(cached_admin_url.as_deref(), Some("http://127.0.0.1:8081"));
let second = state.mq_registry.get_or_build(&updated).await.unwrap(); let second = state.mq_registry.get_or_build(&updated).await.unwrap().adapter;
assert!(!std::sync::Arc::ptr_eq(&first, &second)); assert!(!std::sync::Arc::ptr_eq(&first, &second));
assert!(!state.connections.read().await.contains_key(&initial.id)); assert!(!state.connections.read().await.contains_key(&initial.id));
@ -553,7 +553,7 @@ mod tests {
configs.insert(kept.id.clone(), kept.clone()); configs.insert(kept.id.clone(), kept.clone());
configs.insert(removed.id.clone(), removed.clone()); configs.insert(removed.id.clone(), removed.clone());
} }
let stale = state.mq_registry.get_or_build(&removed).await.unwrap(); let stale = state.mq_registry.get_or_build(&removed).await.unwrap().adapter;
save_connection_configs(&state, std::slice::from_ref(&kept)).await.unwrap(); save_connection_configs(&state, std::slice::from_ref(&kept)).await.unwrap();
@ -562,7 +562,7 @@ mod tests {
assert!(!configs.contains_key("removed-mq")); assert!(!configs.contains_key("removed-mq"));
drop(configs); drop(configs);
let rebuilt = state.mq_registry.get_or_build(&removed).await.unwrap(); let rebuilt = state.mq_registry.get_or_build(&removed).await.unwrap().adapter;
assert!(!std::sync::Arc::ptr_eq(&stale, &rebuilt)); assert!(!std::sync::Arc::ptr_eq(&stale, &rebuilt));
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
@ -1085,19 +1085,12 @@ async fn test_connection_with_info_inner(
} }
#[cfg(feature = "mq-admin")] #[cfg(feature = "mq-admin")]
DatabaseType::MessageQueue => { DatabaseType::MessageQueue => {
// Probe with a transient adapter so Test Connection never retains/replaces
// a live cached MQ agent for this connection id (same pattern as Nacos).
let mqc = state.mq_admin_config_for_connection(connection_id, &config).await?; let mqc = state.mq_admin_config_for_connection(connection_id, &config).await?;
let agent_launch = dbx_core::mq::service::resolve_mq_agent_launch_spec(&mqc, state); let agent_launch = dbx_core::mq::service::resolve_mq_agent_launch_spec(&mqc, state);
let adapter = match state.mq_registry.get_or_build_config(connection_id, mqc, agent_launch).await { let adapter = state.mq_registry.build_transient_config(mqc, agent_launch).await?;
Ok(adapter) => adapter, adapter.test_connection().await?;
Err(err) => {
state.mq_registry.drop_connection(connection_id).await;
return Err(err);
}
};
if let Err(err) = adapter.test_connection().await {
state.mq_registry.drop_connection(connection_id).await;
return Err(err);
}
Ok("Connection successful".to_string()) Ok("Connection successful".to_string())
} }
#[cfg(not(feature = "mq-admin"))] #[cfg(not(feature = "mq-admin"))]
@ -1420,8 +1413,8 @@ pub async fn connect_db(
DatabaseType::MessageQueue => { DatabaseType::MessageQueue => {
let mqc = state.mq_admin_config_for_connection(&id, &config).await?; let mqc = state.mq_admin_config_for_connection(&id, &config).await?;
let agent_launch = dbx_core::mq::service::resolve_mq_agent_launch_spec(&mqc, &state); let agent_launch = dbx_core::mq::service::resolve_mq_agent_launch_spec(&mqc, &state);
let adapter = match state.mq_registry.get_or_build_config(&id, mqc, agent_launch).await { let build = match state.mq_registry.get_or_build_config(&id, mqc, agent_launch).await {
Ok(adapter) => adapter, Ok(build) => build,
Err(err) => { Err(err) => {
state.mq_registry.drop_connection(&id).await; state.mq_registry.drop_connection(&id).await;
return Err(err); return Err(err);
@ -1431,7 +1424,7 @@ pub async fn connect_db(
state.mq_registry.drop_connection(&id).await; state.mq_registry.drop_connection(&id).await;
return Err(err); return Err(err);
} }
if let Err(err) = adapter.test_connection().await { if let Err(err) = dbx_core::mq::validate_mq_adapter_after_build(&build).await {
state.mq_registry.drop_connection(&id).await; state.mq_registry.drop_connection(&id).await;
return Err(err); return Err(err);
} }