feat(rocketmq): improve connections, topics, and message queries
This commit is contained in:
parent
0ece86c478
commit
ce7b8c7b74
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -5965,6 +5965,8 @@ export default withEnglishFallback({
|
||||||
beginTime: "開始時間",
|
beginTime: "開始時間",
|
||||||
endTime: "終了時間",
|
endTime: "終了時間",
|
||||||
maxNum: "最大件数",
|
maxNum: "最大件数",
|
||||||
|
invalidTimeRange: "開始時刻または終了時刻が無効です",
|
||||||
|
endTimeMustBeAfterBegin: "終了時刻は開始時刻より後である必要があります",
|
||||||
query: "検索",
|
query: "検索",
|
||||||
querying: "検索中...",
|
querying: "検索中...",
|
||||||
messageOverview: "基本情報",
|
messageOverview: "基本情報",
|
||||||
|
|
|
||||||
|
|
@ -5644,6 +5644,8 @@ export default withEnglishFallback({
|
||||||
beginTime: "시작 시간",
|
beginTime: "시작 시간",
|
||||||
endTime: "종료 시간",
|
endTime: "종료 시간",
|
||||||
maxNum: "최대 메시지 수",
|
maxNum: "최대 메시지 수",
|
||||||
|
invalidTimeRange: "시작 시간 또는 종료 시간이 유효하지 않습니다",
|
||||||
|
endTimeMustBeAfterBegin: "종료 시간은 시작 시간보다 늦어야 합니다",
|
||||||
query: "쿼리",
|
query: "쿼리",
|
||||||
querying: "쿼리 중...",
|
querying: "쿼리 중...",
|
||||||
messageOverview: "개요",
|
messageOverview: "개요",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -6051,6 +6051,8 @@ export default withEnglishFallback({
|
||||||
beginTime: "开始时间",
|
beginTime: "开始时间",
|
||||||
endTime: "结束时间",
|
endTime: "结束时间",
|
||||||
maxNum: "最大条数",
|
maxNum: "最大条数",
|
||||||
|
invalidTimeRange: "开始时间或结束时间无效",
|
||||||
|
endTimeMustBeAfterBegin: "结束时间必须晚于开始时间",
|
||||||
query: "查询",
|
query: "查询",
|
||||||
querying: "查询中...",
|
querying: "查询中...",
|
||||||
messageOverview: "基本信息",
|
messageOverview: "基本信息",
|
||||||
|
|
|
||||||
|
|
@ -5905,6 +5905,8 @@ export default withEnglishFallback({
|
||||||
beginTime: "開始時間",
|
beginTime: "開始時間",
|
||||||
endTime: "結束時間",
|
endTime: "結束時間",
|
||||||
maxNum: "最大條數",
|
maxNum: "最大條數",
|
||||||
|
invalidTimeRange: "開始時間或結束時間無效",
|
||||||
|
endTimeMustBeAfterBegin: "結束時間必須晚於開始時間",
|
||||||
query: "查詢",
|
query: "查詢",
|
||||||
querying: "查詢中...",
|
querying: "查詢中...",
|
||||||
messageOverview: "基本資訊",
|
messageOverview: "基本資訊",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue