This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 4781e3ee4 fix(dlq): confine resends to the owning instance and
cluster, and stop lossy exports (#5448)
4781e3ee4 is described below
commit 4781e3ee46c757d009ac7acf0a93f3c300f9fb93
Author: 烤化の初雪 <[email protected]>
AuthorDate: Fri Oct 9 17:47:09 2026 +0800
fix(dlq): confine resends to the owning instance and cluster, and stop
lossy exports (#5448)
Four fixes on the DLQ / message-redelivery path, same author and same files.
- A resend was not gated on ownership of the group and the target topic, so
a request could act on a
resource belonging to another instance; `DLQService` now resolves both
through
`ResourceOwnershipGuard`. (#5448)
- `validateResendTargetTopic` checked the target topic against every
cluster on the NameServer rather
than against the DLQ topic's own cluster. It now takes the DLQ topic and
compares clusters, and a
route lookup that fails falls through instead of turning the "no route
yet" 404 into a 502. (#5451)
- The Excel export wrote message bodies straight into a cell, where POI's
32767-character limit cut
them - possibly mid surrogate pair; bodies are now abbreviated on a
character boundary, with the
raw bytes still available in the `Body Base64` column. (#5424)
- Redelivery accepted a message whose properties had been truncated for
display, i.e. a projection
that cannot prove what the original carried; `requireExactSourceMessage`
now refuses when the
property cap was reached or a user property was abbreviated. (#5427)
`RocketMQDLQProviderTest`, `DLQServiceTest`, `DLQControllerTest`,
`DLQMessageExcelRowTest`,
`MetadataServiceTest`, `MessageRedeliveryToolHandlerTest`: 173 tests green
with the dev profile,
0 checkstyle violations. Rebased onto today's trunk, keeping both the `Body
Base64` column (#5205)
and the abbreviated body.
Folded in #5424, #5427 and #5451.
---
.../studio/common/util/MessagePropertyDisplay.java | 5 +-
.../studio/instance/dlq/DLQMessageExcelRow.java | 22 ++-
.../rocketmq/studio/instance/dlq/DLQService.java | 35 +++-
.../studio/instance/topic/MetadataService.java | 75 +++++++-
.../message/MessageRedeliveryToolHandler.java | 9 +-
.../provider/apache/RocketMQDLQProvider.java | 69 +++++++-
.../instance/dlq/DLQMessageExcelRowTest.java | 17 ++
.../studio/instance/dlq/DLQServiceTest.java | 65 +++++++
.../studio/instance/topic/MetadataServiceTest.java | 189 +++++++++++++++++++++
.../message/MessageRedeliveryToolHandlerTest.java | 51 ++++++
.../provider/apache/RocketMQDLQProviderTest.java | 106 ++++++++++++
11 files changed, 628 insertions(+), 15 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
b/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
index 66b8e3f37..d008cdc37 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
@@ -34,6 +34,9 @@ public final class MessagePropertyDisplay {
public static final int MAX_PROPERTIES = 64;
+ /** Marker appended by {@link #abbreviate} to a value it cut at the
code-point cap. */
+ public static final String ABBREVIATION_SUFFIX = "...";
+
/**
* Caps a single property value by code point rather than by UTF-16 {@code
char}. The two differ
* for supplementary characters (an emoji, a CJK extension character),
which occupy two chars:
@@ -86,6 +89,6 @@ public final class MessagePropertyDisplay {
private static String abbreviate(String value) {
// A property value is end-user text, so the cut has to land on a code
point boundary: it is
// always passed through whole or abbreviated with the ellipsis, never
split mid-character.
- return TextBounds.truncate(value, MAX_PROPERTY_VALUE_CODE_POINTS,
"...");
+ return TextBounds.truncate(value, MAX_PROPERTY_VALUE_CODE_POINTS,
ABBREVIATION_SUFFIX);
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
index 121f47a41..c640c5d29 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
@@ -30,6 +30,14 @@ import java.time.format.DateTimeFormatter;
@Data
public class DLQMessageExcelRow {
+ /**
+ * POI rejects any cell whose text exceeds this many characters, so one
oversized dead-letter
+ * body would otherwise abort the whole export with a 502. The budget is
counted in UTF-16
+ * chars, not code points, which is why {@code TextBounds} cannot be
reused here.
+ */
+ private static final int MAX_EXCEL_CELL_CHARS = 32_767;
+ private static final String TRUNCATION_SUFFIX = "...[truncated]";
+
private static final DateTimeFormatter STORE_TIME_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -62,8 +70,20 @@ public class DLQMessageExcelRow {
Instant.ofEpochMilli(vo.getStoreTime()),
ZoneId.systemDefault()).format(STORE_TIME_FORMAT));
row.setReconsumeTimes(vo.getReconsumeTimes());
row.setKeys(vo.getKeys());
- row.setBody(vo.getBody());
+ row.setBody(abbreviateBody(vo.getBody()));
row.setBodyBase64(vo.getBody() == null ? vo.getBodyBase64() : null);
return row;
}
+
+ private static String abbreviateBody(String body) {
+ if (body == null || body.length() <= MAX_EXCEL_CELL_CHARS) {
+ return body;
+ }
+ int keep = MAX_EXCEL_CELL_CHARS - TRUNCATION_SUFFIX.length();
+ // Back off onto a code-point boundary so the cut never splits a
surrogate pair.
+ if (Character.isHighSurrogate(body.charAt(keep - 1))) {
+ keep--;
+ }
+ return body.substring(0, keep) + TRUNCATION_SUFFIX;
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
index 7acd5c3e3..22a118225 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
@@ -19,13 +19,19 @@ package org.apache.rocketmq.studio.instance.dlq;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard;
+import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Kind;
+import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
+import java.util.ArrayList;
import java.util.List;
+import java.util.function.Supplier;
@Service
@RequiredArgsConstructor
@@ -37,6 +43,7 @@ public class DLQService {
private final DLQProvider dlqProvider;
private final InstanceProviderRegistry providerRegistry;
+ private final ResourceOwnershipGuard ownershipGuard;
public PageResult<DLQGroupVO> listDLQGroups(String instanceId, String
search, int page, int pageSize) {
requireApacheInstance(instanceId);
@@ -61,8 +68,9 @@ public class DLQService {
String normalizedTargetTopic = normalizeOptional(targetTopic);
log.info("Resending DLQ messages: group={}, targetTopic={}",
normalizedGroupName, normalizedTargetTopic);
- return dlqProvider.resendMessages(
- instanceId, normalizedGroupName, startTime, endTime,
normalizedTargetTopic);
+ return withOwnedDlqTargets(instanceId, normalizedGroupName,
normalizedTargetTopic, () ->
+ dlqProvider.resendMessages(
+ instanceId, normalizedGroupName, startTime, endTime,
normalizedTargetTopic));
}
public DLQExportResultVO exportMessages(String instanceId, String
groupName, Long startTime, Long endTime,
@@ -98,8 +106,27 @@ public class DLQService {
String normalizedTargetTopic = normalizeOptional(targetTopic);
log.info("Resending selected DLQ messages: group={}, count={},
targetTopic={}",
normalizedGroupName, normalizedMsgIds.size(),
normalizedTargetTopic);
- return dlqProvider.resendMessages(
- instanceId, normalizedGroupName, normalizedMsgIds,
normalizedTargetTopic);
+ return withOwnedDlqTargets(instanceId, normalizedGroupName,
normalizedTargetTopic, () ->
+ dlqProvider.resendMessages(
+ instanceId, normalizedGroupName, normalizedMsgIds,
normalizedTargetTopic));
+ }
+
+ /**
+ * A resend reads the group's dead-letter topic and publishes back into a
topic, so it is a
+ * group+topic write like the send/redeliver paths - which check exactly
these resources. Without
+ * the check any Apache instance registered against the same cluster could
replay another
+ * instance's dead letters and publish them into a topic it does not own:
the DLQ topic
+ * {@code %DLQ%<group>} belongs to the group's owner, which is what this
guard expresses.
+ */
+ private <T> T withOwnedDlqTargets(String instanceId, String groupName,
String targetTopic,
+ Supplier<T> action) {
+ InstanceVO instance = ownershipGuard.requireInstance(instanceId);
+ List<Resource> resources = new ArrayList<>();
+ resources.add(new Resource(Kind.GROUP, groupName));
+ if (StringUtils.hasText(targetTopic)) {
+ resources.add(ownershipGuard.topicResource(targetTopic));
+ }
+ return ownershipGuard.withOwned(instance, resources, action);
}
public DLQExcelExportResultVO exportExcel(String instanceId, String
groupName, Long startTime, Long endTime,
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index 1cc1562f9..02321edc6 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -41,6 +41,7 @@ import
org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.CsvUtil;
+import org.apache.rocketmq.studio.common.util.MessagePropertyDisplay;
import org.apache.rocketmq.studio.common.util.SystemTopicFilter;
import org.apache.rocketmq.studio.instance.group.CreateConsumerGroupDTO;
import org.apache.rocketmq.studio.instance.group.ImportConsumerGroupsResultVO;
@@ -323,6 +324,7 @@ public class MetadataService {
if (original == null) {
throw new BusinessException(400, "Message to redeliver must not be
null");
}
+ requireExactSourceMessage(original);
String destination = StringUtils.hasText(targetTopic)
? targetTopic.trim()
: MixAll.getRetryTopic(group);
@@ -341,6 +343,71 @@ public class MetadataService {
return ownershipGuard.withOwned(instance, resources, () ->
sendMessage(request));
}
+ /**
+ * A source loaded by {@link #findMessageForRedelivery} is the message
explorer's display
+ * projection, not the stored record: the body stops at {@code
MAX_BODY_DISPLAY_BYTES}, a binary
+ * body is replaced by Base64 text, and the property map is capped.
Publishing that projection
+ * stores different bytes and silently drops properties, so a lossy source
is refused instead.
+ * <p>The property check fires on the two loss modes the projection can
prove. Reaching
+ * {@link MessagePropertyDisplay#MAX_PROPERTIES} entries means whole
entries were dropped: the
+ * projection cannot say which ones, but the cap itself is the proof, and
since
+ * {@code limitProperties} sorts by key the lowercase user keys are
exactly what is lost. An
+ * abbreviated value on a visible user key is the other mode.
System-reserved keys alone are
+ * never enough — {@link #redeliveryProperties} discards them before the
send, so a map that
+ * stayed under the cap with intact values republishes the same user
properties.
+ * <p>Refuses display projections whose body or user properties cannot be
republished exactly.
+ */
+ public void requireExactSourceMessage(MessageRecordVO original) {
+ if (original.isBodyTruncated()) {
+ throw new BusinessException(409,
+ "Source message body is truncated for display and cannot
be redelivered exactly");
+ }
+ if ("BASE64".equalsIgnoreCase(original.getBodyEncoding())) {
+ throw new BusinessException(409,
+ "Source message body is binary and cannot be redelivered
exactly");
+ }
+ if (original.isPropertiesTruncated() &&
isPropertyDisplayCapReached(original.getProperties())) {
+ throw new BusinessException(409,
+ "Source message properties reached the display cap, so
whole entries were dropped "
+ + "and cannot be redelivered exactly");
+ }
+ if (original.isPropertiesTruncated() &&
hasAbbreviatedUserProperty(original.getProperties())) {
+ throw new BusinessException(409,
+ "Source message properties are truncated for display and
cannot be redelivered exactly");
+ }
+ }
+
+ /**
+ * True when the projection kept {@link
MessagePropertyDisplay#MAX_PROPERTIES} entries, which
+ * happens only if the raw map held more and the overflow was dropped
whole.
+ */
+ private static boolean isPropertyDisplayCapReached(Map<String, String>
properties) {
+ return properties != null && properties.size() >=
MessagePropertyDisplay.MAX_PROPERTIES;
+ }
+
+ /**
+ * True when a <em>user</em> property visible in the display projection
carries an abbreviated
+ * value: republishing it would store a cut value. System-reserved keys
are excluded —
+ * {@link #redeliveryProperties} drops them before the send, abbreviated
or not.
+ */
+ private static boolean hasAbbreviatedUserProperty(Map<String, String>
properties) {
+ if (properties == null || properties.isEmpty()) {
+ return false;
+ }
+ return properties.entrySet().stream()
+ .anyMatch(entry -> !isSystemProperty(entry.getKey())
+ && isAbbreviatedValue(entry.getValue()));
+ }
+
+ /**
+ * Mirrors the display projection's abbreviation: any value whose rendered
form ends in the
+ * {@code ABBREVIATION_SUFFIX} marker was cut, whether it sits on a user
or a system key.
+ */
+ private static boolean isAbbreviatedValue(String value) {
+ return value != null && value.length() >=
MessagePropertyDisplay.ABBREVIATION_SUFFIX.length()
+ && value.endsWith(MessagePropertyDisplay.ABBREVIATION_SUFFIX);
+ }
+
/** Drops the system-reserved keys {@code Message.putUserProperty} would
reject (§15.5.1 KEYS defect). */
private static Map<String, String> redeliveryProperties(Map<String,
String> properties) {
if (properties == null || properties.isEmpty()) {
@@ -365,7 +432,13 @@ public class MetadataService {
|| key.startsWith(MixAll.DLQ_GROUP_TOPIC_PREFIX);
}
- /** Loads the exact source message used by redelivery without exposing or
publishing its body. */
+ /**
+ * Loads the message-explorer record used as the redelivery source without
exposing or
+ * publishing its body. This is the explorer's <em>display
projection</em>, not the stored
+ * record: the body is capped and binary bodies are Base64-text, so
+ * {@link #redeliverMessage(String, String, MessageRecordVO, String)}
refuses lossy sources
+ * instead of republishing them.
+ */
public MessageRecordVO findMessageForRedelivery(String instanceId, String
sourceTopic, String msgId) {
String messageId = requireName(msgId, "message id");
List<MessageRecordVO> matches = messageService.queryMessages(
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
index 1a6d8e8d4..b9001ae90 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandler.java
@@ -43,7 +43,11 @@ public class MessageRedeliveryToolHandler extends
MutationToolHandler<MessageRed
private static final PlanDescription PLAN_DESCRIPTION = new
PlanDescription(
"redeliver message '%s' for consumer group '%s' in instance '%s'.",
List.of("Publishes a new message containing the source payload and
properties."),
- List.of());
+ List.of("The source is the message explorer's display projection;
a lossy source "
+ + "(truncated or binary body, abbreviated user properties)
is refused "
+ + "before the plan is produced.",
+ "A complete user value that happens to end in '...' is
read as abbreviated "
+ + "while the truncated flag is set, so such a source is
refused too."));
private final MetadataService metadataService;
@@ -62,6 +66,9 @@ public class MessageRedeliveryToolHandler extends
MutationToolHandler<MessageRed
String groupName = requireGroupName(input);
MessageRecordVO source = metadataService.findMessageForRedelivery(
context.instanceId(), input.sourceTopic(), input.msgId());
+ // Same exact-source guard execute() applies, so the plan can never
promise a
+ // publish that the execution stage would refuse.
+ metadataService.requireExactSourceMessage(source);
String destination = resolveDestination(input, groupName);
return PLAN_DESCRIPTION.builder(input.msgId(), groupName,
context.instanceId())
.before(new RedeliverySource(source.getMsgId(),
source.getTopic(), source.getTag(), source.getKey(),
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
index 0578f23c3..3365a8fb7 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
@@ -29,6 +29,8 @@ import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.common.message.MessageQueue;
import org.apache.rocketmq.common.topic.TopicValidator;
import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
import org.apache.rocketmq.remoting.protocol.admin.TopicOffset;
import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
import org.apache.rocketmq.remoting.protocol.body.TopicList;
@@ -71,7 +73,9 @@ import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
+import java.util.stream.Collectors;
/**
* Real {@link DLQProvider} backed by the RocketMQ admin API. Lists
dead-letter groups by scanning
@@ -189,12 +193,11 @@ public class RocketMQDLQProvider implements DLQProvider {
if (begin >= end) {
throw new BusinessException(400, "DLQ resend start time must be
before end time");
}
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
if (StringUtils.hasText(targetTopic)) {
- validateResendTargetTopic(instanceId, targetTopic);
+ validateResendTargetTopic(instanceId, dlqTopic, targetTopic);
}
- String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
-
DeadLetterScanResult scanResult;
try {
scanResult = collectDeadLetters(instanceId, dlqTopic, begin, end,
RESEND_HARD_CAP);
@@ -262,12 +265,11 @@ public class RocketMQDLQProvider implements DLQProvider {
if (selected.isEmpty()) {
throw new BusinessException(400, "At least one valid msgId is
required for selected DLQ resend");
}
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
if (StringUtils.hasText(targetTopic)) {
- validateResendTargetTopic(instanceId, targetTopic);
+ validateResendTargetTopic(instanceId, dlqTopic, targetTopic);
}
- String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
-
List<MessageExt> deadLetters =
runtimeAdminClientResolver.execute(instanceId, admin -> {
List<MessageExt> resolved = new ArrayList<>(selected.size());
for (String msgId : selected) {
@@ -673,7 +675,7 @@ public class RocketMQDLQProvider implements DLQProvider {
* create new topics on clusters with autoCreateTopicEnable. Restrict it
to valid, existing,
* non-system topics on the selected instance.
*/
- private void validateResendTargetTopic(String instanceId, String
targetTopic) {
+ private void validateResendTargetTopic(String instanceId, String dlqTopic,
String targetTopic) {
TopicValidator.ValidateResult validity =
TopicValidator.validateTopic(targetTopic);
if (!validity.isValid()) {
throw new BusinessException(400, "targetTopic is not a valid
RocketMQ topic name: "
@@ -701,6 +703,59 @@ public class RocketMQDLQProvider implements DLQProvider {
"targetTopic does not exist on the selected instance;
create the topic before resending: "
+ targetTopic);
}
+ requireSameClusterAsDlqTopic(instanceId, dlqTopic, targetTopic);
+ }
+
+ /**
+ * A resend must land in the cluster the dead-letter topic belongs to. The
existence check above
+ * asks the NameServer, and one NameServer can serve several clusters, so
a target topic that only
+ * exists in a sibling cluster passes it - and the producer, which routes
by topic name across the
+ * whole endpoint, would then publish the replayed messages into that
other cluster while the API
+ * and the audit record report success. Sibling write paths (send, offset
reset, direct consume)
+ * reject exactly this with {@code
ApacheWriteTargetResolver.requireTopicRoute}.
+ *
+ * <p>Only a resolvable route can prove the mismatch, so an unresolvable
one is left to the send
+ * itself to fail - in particular a {@code %DLQ%<group>} that has no route
yet must keep reaching
+ * the scan's precise 404 rather than being reported as a route failure.
+ */
+ private void requireSameClusterAsDlqTopic(String instanceId, String
dlqTopic, String targetTopic) {
+ try {
+ runtimeAdminClientResolver.execute(instanceId, admin -> {
+ Set<String> dlqClusters = clustersOfRoute(admin, dlqTopic);
+ Set<String> targetClusters = clustersOfRoute(admin,
targetTopic);
+ if (!dlqClusters.isEmpty() && !targetClusters.isEmpty()
+ && !dlqClusters.containsAll(targetClusters)) {
+ throw new BusinessException(409, "targetTopic resolves to
a different cluster than "
+ + dlqTopic + "; resend rejected: " + targetTopic);
+ }
+ return null;
+ });
+ } catch (BusinessException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new BusinessException(502, "Failed to verify the targetTopic
route: " + e.getMessage());
+ }
+ }
+
+ /** An unresolvable route proves nothing, so a failed lookup contributes
no cluster. */
+ private Set<String> clustersOfRoute(MQAdminExt admin, String topic) {
+ try {
+ return clustersOf(admin.examineTopicRouteInfo(topic));
+ } catch (Exception e) {
+ log.debug("Cannot resolve the route of {} for the DLQ cluster
check: {}", topic, e.getMessage());
+ return Collections.emptySet();
+ }
+ }
+
+ private static Set<String> clustersOf(TopicRouteData route) {
+ if (route == null || route.getBrokerDatas() == null) {
+ return Collections.emptySet();
+ }
+ return route.getBrokerDatas().stream()
+ .filter(Objects::nonNull)
+ .map(BrokerData::getCluster)
+ .filter(StringUtils::hasText)
+ .collect(Collectors.toSet());
}
private String resolveTargetTopic(MessageExt deadLetter, String
targetTopic) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRowTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRowTest.java
index c821ff7f1..0747a6566 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRowTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRowTest.java
@@ -63,6 +63,23 @@ class DLQMessageExcelRowTest {
assertThat(cells).containsEntry("Body Base64", "wyg=");
}
+ @Test
+ void shouldClampAnOversizedBodyOnACodePointBoundaryTest() {
+ // Asserted on the row rather than on a written sheet: POI sanitises
an unpaired surrogate
+ // on the way out, so only the in-memory cell shows whether the cut
split a pair. The astral
+ // run reaches index 32753, putting a high surrogate exactly at the
32752 cut point.
+ DLQMessageVO message = DLQMessageVO.builder()
+ .msgId("msg-huge")
+ .storeTime(1_700_000_000_000L)
+ .body("\uD835\uDC00".repeat(16_377) + "x".repeat(40_000))
+ .build();
+
+ String cell = DLQMessageExcelRow.from(message).getBody();
+
+ assertThat(cell).hasSize(32_766).endsWith("...[truncated]");
+ assertThat(cell.codePoints()).noneMatch(codePoint -> codePoint >=
0xD800 && codePoint <= 0xDFFF);
+ }
+
private static Map<String, String>
firstDataRowByColumnName(List<DLQMessageVO> messages) {
List<Map<Integer, String>> rows = readBack(messages);
Map<Integer, String> header = rows.get(0);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
index a08a31803..df87b1dd5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
@@ -19,7 +19,13 @@ package org.apache.rocketmq.studio.instance.dlq;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard;
+import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Kind;
+import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -27,9 +33,17 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
+import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -43,9 +57,25 @@ class DLQServiceTest {
@Mock
private InstanceProviderRegistry providerRegistry;
+ @Mock
+ private ResourceOwnershipGuard ownershipGuard;
+
@InjectMocks
private DLQService dlqService;
+ @BeforeEach
+ void ownEveryResource() {
+ // The ownership check is what the resend tests are not about: let it
pass by default and keep
+ // the mocked provider reachable through the wrapper. Lenient because
the validation-failure
+ // tests never reach it.
+ lenient().when(ownershipGuard.requireInstance(anyString()))
+
.thenReturn(InstanceVO.builder().name("instance-1").vendor(InstanceVendor.APACHE).build());
+ lenient().when(ownershipGuard.topicResource(anyString()))
+ .thenAnswer(invocation -> new Resource(Kind.TOPIC,
invocation.getArgument(0)));
+ lenient().when(ownershipGuard.withOwned(any(), anyList(), any()))
+ .thenAnswer(invocation -> ((Supplier<?>)
invocation.getArgument(2)).get());
+ }
+
@Test
void listDLQGroupsShouldReturnGroupsFromProvider() {
List<DLQGroupVO> groups = List.of(
@@ -89,6 +119,41 @@ class DLQServiceTest {
verify(dlqProvider).resendMessages("instance-1", "group-1", 1000L,
2000L, "target-topic");
}
+ @Test
+ void resendMessagesShouldCheckOwnershipOfTheGroupAndTheTargetTopicTest() {
+ dlqService.resendMessages("instance-1", "group-1", 1000L, 2000L,
"target-topic");
+
+ // The DLQ topic %DLQ%group-1 belongs to the group's owner, and the
destination is a topic
+ // write of its own: the resend must be gated on both.
+ verify(ownershipGuard).withOwned(
+ any(InstanceVO.class),
+ eq(List.of(new Resource(Kind.GROUP, "group-1"), new
Resource(Kind.TOPIC, "target-topic"))),
+ any());
+ }
+
+ @Test
+ void
resendSelectedMessagesShouldCheckOwnershipOfTheGroupOnlyWhenNoTargetIsNamedTest()
{
+ dlqService.resendSelectedMessages("instance-1", "group-1",
List.of("msg-1"), null);
+
+ verify(ownershipGuard).withOwned(
+ any(InstanceVO.class),
+ eq(List.of(new Resource(Kind.GROUP, "group-1"))),
+ any());
+ }
+
+ @Test
+ void resendMessagesShouldNotReachTheProviderWhenOwnershipRefusesTest() {
+ // doThrow: the when(...) form would invoke the mock and run the
lenient answer below.
+ doThrow(new BusinessException(409, "Resource is owned by another
instance"))
+ .when(ownershipGuard).withOwned(any(), anyList(), any());
+
+ assertThatThrownBy(() ->
+ dlqService.resendMessages("instance-1", "group-1", 1000L,
2000L, null))
+ .isInstanceOfSatisfying(BusinessException.class,
+ exception ->
assertThat(exception.getCode()).isEqualTo(409));
+ verify(dlqProvider, never()).resendMessages(anyString(), anyString(),
any(), any(), any());
+ }
+
@Test
void actionsShouldNormalizeIdentifiersBeforeDelegatingTest() {
List<String> msgIds = List.of(" msg-1 ", " msg-2 ");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index b94c5db76..64daa4ec7 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -36,6 +36,7 @@ import
org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
import org.apache.rocketmq.studio.common.domain.enums.TopicType;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.MessagePropertyDisplay;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.CreateConsumerGroupDTO;
import org.apache.rocketmq.studio.instance.group.ImportConsumerGroupsResultVO;
@@ -199,6 +200,194 @@ class MetadataServiceTest {
assertThat(request.getValue().getKey()).isEqualTo("order-1");
}
+ @Test
+ void redeliverMessageShouldRefuseATruncatedSourceBodyTest() {
+ // The message explorer hands out a display projection whose body
stops at 64 KiB; publishing
+ // that projection as the redelivered payload drops the rest of the
source message.
+ MessageRecordVO original = MessageRecordVO.builder()
+ .msgId("msg-original")
+ .topic("orders")
+ .body("x".repeat(65536))
+ .bodyEncoding("UTF-8")
+ .bodyTruncated(true)
+ .build();
+ when(messageService.queryMessages(
+ "instance-a", "orders", "msg-original", null, null, null,
null))
+ .thenReturn(List.of(original));
+
+ assertThatThrownBy(() -> metadataService.redeliverMessage(
+ "instance-a", "group-a", "orders", "msg-original",
"orders-copy"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("truncated")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(409));
+
+ verify(apacheProvider, never()).sendMessage(any(SendMessageDTO.class));
+ }
+
+ @Test
+ void redeliverMessageShouldRefuseABase64SourceBodyTest() {
+ // A binary body is projected as Base64 text; republishing that text
stores different bytes.
+ MessageRecordVO original = MessageRecordVO.builder()
+ .msgId("msg-original")
+ .topic("orders")
+ .body("AAECAwQ=")
+ .bodyEncoding("BASE64")
+ .build();
+ when(messageService.queryMessages(
+ "instance-a", "orders", "msg-original", null, null, null,
null))
+ .thenReturn(List.of(original));
+
+ assertThatThrownBy(() -> metadataService.redeliverMessage(
+ "instance-a", "group-a", "orders", "msg-original", null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("binary")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(409));
+
+ verify(apacheProvider, never()).sendMessage(any(SendMessageDTO.class));
+ }
+
+ @Test
+ void redeliverMessageShouldRefuseATruncatedSourcePropertySetTest() {
+ // The flag is true AND a visible user property carries an abbreviated
value: the
+ // user-property set really is incomplete, so the redelivery must be
refused.
+ MessageRecordVO original = MessageRecordVO.builder()
+ .msgId("msg-original")
+ .topic("orders")
+ .body("payload")
+ .properties(Map.of("tenant", "alpha..."))
+ .propertiesTruncated(true)
+ .build();
+ when(messageService.queryMessages(
+ "instance-a", "orders", "msg-original", null, null, null,
null))
+ .thenReturn(List.of(original));
+
+ assertThatThrownBy(() -> metadataService.redeliverMessage(
+ "instance-a", "group-a", "orders", "msg-original", null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("properties")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(409));
+
+ verify(apacheProvider, never()).sendMessage(any(SendMessageDTO.class));
+ }
+
+ @Test
+ void
redeliverMessageShouldAllowATruncatedFlagCausedOnlyBySystemPropertiesTest() {
+ // propertiesTruncated is computed over the raw map including broker
system keys, but
+ // redelivery discards system keys anyway: with no visible user
property abbreviated,
+ // the user-property set is intact and the redelivery must go through.
+ Map<String, String> properties = new HashMap<>();
+ properties.put(MessageConst.PROPERTY_KEYS, "order-1");
+ properties.put(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX,
"uniq");
+ properties.put(MessageConst.PROPERTY_REAL_TOPIC, "orders");
+ properties.put("tenant", "alpha");
+ MessageRecordVO original = MessageRecordVO.builder()
+ .msgId("msg-original")
+ .topic("orders")
+ .body("payload")
+ .properties(properties)
+ .propertiesTruncated(true)
+ .build();
+ when(messageService.queryMessages(
+ "instance-a", "orders", "msg-original", null, null, null,
null))
+ .thenReturn(List.of(original));
+ when(apacheProvider.sendMessage(any(SendMessageDTO.class)))
+ .thenReturn(SendMessageVO.builder().msgId("msg-new").build());
+
+ metadataService.redeliverMessage("instance-a", "group-a", "orders",
"msg-original", null);
+
+ ArgumentCaptor<SendMessageDTO> request =
ArgumentCaptor.forClass(SendMessageDTO.class);
+ verify(apacheProvider).sendMessage(request.capture());
+
assertThat(request.getValue().getProperties()).containsExactlyEntriesOf(Map.of("tenant",
"alpha"));
+ }
+
+ @Test
+ void
redeliverMessageShouldAllowATruncatedFlagWithAnIntactUserPropertySetBelowTheCapTest()
{
+ // MessageConst.STRING_HASH_SET holds 54 system keys, so this
projection stays below the
+ // 64-entry cap; with no abbreviated value the two user properties are
provably intact.
+ Map<String, String> properties = new HashMap<>();
+ for (String key : MessageConst.STRING_HASH_SET) {
+ properties.put(key, "v");
+ }
+ properties.put("tenant", "alpha");
+ properties.put("region", "cn-hangzhou");
+
assertThat(properties).hasSizeLessThan(MessagePropertyDisplay.MAX_PROPERTIES);
+ MessageRecordVO original = MessageRecordVO.builder()
+ .msgId("msg-original")
+ .topic("orders")
+ .body("payload")
+ .properties(properties)
+ .propertiesTruncated(true)
+ .build();
+ when(messageService.queryMessages(
+ "instance-a", "orders", "msg-original", null, null, null,
null))
+ .thenReturn(List.of(original));
+ when(apacheProvider.sendMessage(any(SendMessageDTO.class)))
+ .thenReturn(SendMessageVO.builder().msgId("msg-new").build());
+
+ metadataService.redeliverMessage("instance-a", "group-a", "orders",
"msg-original", null);
+
+ ArgumentCaptor<SendMessageDTO> request =
ArgumentCaptor.forClass(SendMessageDTO.class);
+ verify(apacheProvider).sendMessage(request.capture());
+ assertThat(request.getValue().getProperties())
+ .containsExactlyInAnyOrderEntriesOf(Map.of("tenant", "alpha",
"region", "cn-hangzhou"));
+ }
+
+ @Test
+ void
redeliverMessageShouldRefuseAPropertySetThatReachedTheDisplayCapTest() {
+ // A projection holding exactly MAX_PROPERTIES entries can only come
from a raw map that
+ // had more, and limitProperties dropped the overflow whole - sorted
by key, that overflow
+ // is the lowercase user keys, so the republished message would carry
fewer properties.
+ Map<String, String> properties = new HashMap<>();
+ for (String key : MessageConst.STRING_HASH_SET) {
+ properties.put(key, "v");
+ }
+ for (int i = properties.size(); i <
MessagePropertyDisplay.MAX_PROPERTIES; i++) {
+ properties.put("user" + i, "v");
+ }
+ assertThat(properties).hasSize(MessagePropertyDisplay.MAX_PROPERTIES);
+ MessageRecordVO original = MessageRecordVO.builder()
+ .msgId("msg-original")
+ .topic("orders")
+ .body("payload")
+ .properties(properties)
+ .propertiesTruncated(true)
+ .build();
+ when(messageService.queryMessages(
+ "instance-a", "orders", "msg-original", null, null, null,
null))
+ .thenReturn(List.of(original));
+
+ assertThatThrownBy(() -> metadataService.redeliverMessage(
+ "instance-a", "group-a", "orders", "msg-original", null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("display cap")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(409));
+
+ verify(apacheProvider, never()).sendMessage(any(SendMessageDTO.class));
+ }
+
+ @Test
+ void
redeliverMessageShouldAllowNonSystemPropertiesWhoseValuesEndInEllipsisTest() {
+ // A complete user value can legitimately end in "...": without
propertiesTruncated the
+ // abbreviation marker alone must never refuse a redelivery.
+ MessageRecordVO original = MessageRecordVO.builder()
+ .msgId("msg-original")
+ .topic("orders")
+ .body("payload")
+ .properties(Map.of("tenant", "alpha..."))
+ .build();
+ when(messageService.queryMessages(
+ "instance-a", "orders", "msg-original", null, null, null,
null))
+ .thenReturn(List.of(original));
+ when(apacheProvider.sendMessage(any(SendMessageDTO.class)))
+ .thenReturn(SendMessageVO.builder().msgId("msg-new").build());
+
+ metadataService.redeliverMessage("instance-a", "group-a", "orders",
"msg-original", null);
+
+ ArgumentCaptor<SendMessageDTO> request =
ArgumentCaptor.forClass(SendMessageDTO.class);
+ verify(apacheProvider).sendMessage(request.capture());
+
assertThat(request.getValue().getProperties()).containsExactlyEntriesOf(Map.of("tenant",
"alpha..."));
+ }
+
@Test
void redeliverMessageShouldRejectBlankGroupNameTest() {
assertThatThrownBy(() -> metadataService.redeliverMessage(
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandlerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandlerTest.java
index 22238cbb0..22ffce1a2 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandlerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryToolHandlerTest.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.ops.ai.tool.handler.message;
import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.instance.topic.MetadataService;
import org.apache.rocketmq.studio.instance.topic.SendMessageVO;
import
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageRedeliveryInput;
@@ -36,6 +37,9 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -87,6 +91,53 @@ class MessageRedeliveryToolHandlerTest {
new MessageRedeliveryOutput("original", "redelivered",
"TopicB"));
}
+ @Test
+ void previewRefusesALossySourceInsteadOfPromisingAnUnexecutablePlanTest() {
+ MetadataService metadata = mock(MetadataService.class);
+ MessageRedeliveryToolHandler handler = new
MessageRedeliveryToolHandler(metadata);
+ ToolExecutionContext execution = context("instance-a");
+ MessageRedeliveryInput input =
+ new MessageRedeliveryInput("instance-a", "group-a",
"original", "orders", null);
+ MessageRecordVO lossySource =
MessageRecordVO.builder().msgId("original").topic("orders")
+ .body("x").bodyTruncated(true).build();
+ when(metadata.findMessageForRedelivery("instance-a", "orders",
"original"))
+ .thenReturn(lossySource);
+ doThrow(new BusinessException(409,
+ "Source message body is truncated for display and cannot be
redelivered exactly"))
+ .when(metadata).requireExactSourceMessage(lossySource);
+
+ assertThatThrownBy(() -> handler.preview(input, execution))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("truncated");
+
+ verify(metadata).requireExactSourceMessage(lossySource);
+ verify(metadata, never()).redeliverMessage(
+ any(), any(), any(MessageRecordVO.class), any());
+ }
+
+ @Test
+ void previewAndExecuteUseTheSameExactSourceGuardTest() {
+ // The guard method called in preview is exactly the one
redeliverMessage runs before
+ // publishing, so a preview that passes cannot be refused at execution
time.
+ MetadataService metadata = mock(MetadataService.class);
+ MessageRedeliveryToolHandler handler = new
MessageRedeliveryToolHandler(metadata);
+ ToolExecutionContext execution = context("instance-a");
+ MessageRedeliveryInput input =
+ new MessageRedeliveryInput("instance-a", "group-a",
"original", "orders", null);
+ MessageRecordVO source =
MessageRecordVO.builder().msgId("original").topic("orders")
+ .body("payload").build();
+ when(metadata.findMessageForRedelivery("instance-a", "orders",
"original"))
+ .thenReturn(source);
+ when(metadata.redeliverMessage("instance-a", "group-a", "orders",
"original", null))
+
.thenReturn(SendMessageVO.builder().msgId("redelivered").build());
+
+ ToolPlan plan = handler.preview(input, execution);
+
+ verify(metadata).requireExactSourceMessage(source);
+ assertThat(plan.summary()).isEqualTo(
+ "redeliver message 'original' for consumer group 'group-a' in
instance 'instance-a'.");
+ }
+
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", " "})
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
index 8596179c9..d8aaa2630 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.provider.apache;
+import com.alibaba.excel.EasyExcel;
import org.apache.rocketmq.client.consumer.DefaultMQPullConsumer;
import org.apache.rocketmq.client.consumer.PullResult;
import org.apache.rocketmq.client.consumer.PullStatus;
@@ -35,11 +36,13 @@ import
org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.body.TopicList;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
import org.apache.rocketmq.studio.cluster.broker.MqClientPool;
import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.dlq.DLQExcelExportResultVO;
import org.apache.rocketmq.studio.instance.dlq.DLQExportResultVO;
import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
import org.apache.rocketmq.studio.instance.dlq.DLQMessageVO;
@@ -54,6 +57,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.io.ByteArrayInputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@@ -294,6 +298,60 @@ class RocketMQDLQProviderTest {
verify(pullConsumer, never()).pull(any(MessageQueue.class),
anyString(), anyLong(), anyInt());
}
+ @Test
+ void resendRejectsATargetTopicThatResolvesToAnotherClusterTest() throws
Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ TopicList existingTargets = new TopicList();
+ existingTargets.setTopicList(Set.of("target-topic"));
+ when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
+ // One NameServer can serve several clusters, so the target exists on
the endpoint while
+ // belonging to a different cluster than the dead-letter topic it
would be replayed from.
+ // The producer routes by topic name and would publish into that other
cluster silently.
+
when(adminExt.examineTopicRouteInfo(dlqTopic)).thenReturn(routeInCluster("cluster-a"));
+
when(adminExt.examineTopicRouteInfo("target-topic")).thenReturn(routeInCluster("cluster-b"));
+
+ assertThatThrownBy(() -> provider.resendMessages(
+ "instance-a", "group-a", 100L, 200L, "target-topic"))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(409);
+ assertThat(exception.getMessage()).contains("different
cluster");
+ });
+ verify(runtimeAdminClientResolver,
never()).executeProducer(anyString(), any());
+ }
+
+ private static TopicRouteData routeInCluster(String cluster) {
+ BrokerData broker = new BrokerData();
+ broker.setCluster(cluster);
+ broker.setBrokerName("broker-a");
+ broker.setBrokerAddrs(new HashMap<>(Map.of(0L, "10.0.0.1:10911")));
+ TopicRouteData route = new TopicRouteData();
+ route.setBrokerDatas(List.of(broker));
+ return route;
+ }
+
+ @Test
+ void resendLeavesAnUnresolvableDlqRouteToTheScanNotFoundTest() throws
Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ TopicList existingTargets = new TopicList();
+ existingTargets.setTopicList(Set.of("target-topic"));
+ when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
+ // Only a resolvable route can prove a cluster mismatch, so a
dead-letter topic that has no
+ // route yet must fall through to the scan's precise 404 instead of a
route-verification 502.
+ when(adminExt.examineTopicRouteInfo(dlqTopic)).thenThrow(new
MQClientException(
+ "No topic route info in name server for the topic: " +
dlqTopic, null));
+
when(adminExt.examineTopicRouteInfo("target-topic")).thenReturn(routeInCluster("cluster-b"));
+ when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic))
+ .thenThrow(new MQClientException("Can not find Message Queue
for this topic, " + dlqTopic, null));
+
+ assertThatThrownBy(() -> provider.resendMessages(
+ "instance-a", "group-a", 100L, 200L, "target-topic"))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(404));
+ verify(auditService).record(eq("RESEND_DLQ"), eq("DLQ"),
eq("group-a"), isNull(),
+ contains("dlqTopicMissing=true"), eq("NOT_FOUND"));
+ verify(runtimeAdminClientResolver,
never()).executeProducer(anyString(), any());
+ }
+
@Test
void resendSelectedMessagesRejectsSystemTopicAsTarget() throws Exception {
assertThatThrownBy(() -> provider.resendMessages(
@@ -1110,6 +1168,54 @@ class RocketMQDLQProviderTest {
verify(pullConsumer, never()).searchOffset(queue, Long.MIN_VALUE);
}
+ @Test
+ void
exportExcelTruncatesOversizedBodiesInsteadOfFailingTheWholeExportTest() throws
Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ MessageQueue queue = new MessageQueue(dlqTopic, "broker-a", 0);
+ MessageExt oversized = new MessageExt();
+ oversized.setMsgId("msg-huge");
+ oversized.setTopic(dlqTopic);
+ oversized.setQueueId(0);
+ oversized.setQueueOffset(5L);
+ oversized.setStoreTimestamp(150L);
+ oversized.setKeys("key-huge");
+ // Leading astral characters put a high surrogate exactly at the
truncation boundary,
+ // so the cut must back off onto a code-point boundary.
+ oversized.setBody(("\uD835\uDC00".repeat(16_377) + "x".repeat(40_000))
+ .getBytes(StandardCharsets.UTF_8));
+ MessageExt small = new MessageExt();
+ small.setMsgId("msg-small");
+ small.setTopic(dlqTopic);
+ small.setQueueId(0);
+ small.setQueueOffset(6L);
+ small.setStoreTimestamp(160L);
+ small.setKeys("key-small");
+ small.setBody("hello dlq".getBytes(StandardCharsets.UTF_8));
+ PullResult pullResult = new PullResult(PullStatus.FOUND, 2L, 0L, 0L,
List.of(oversized, small));
+
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(queue));
+ // The scan resolves the window with searchOffset(begin) and
searchOffset(end + 1).
+ when(pullConsumer.searchOffset(eq(queue), anyLong())).thenReturn(0L,
2L);
+ when(pullConsumer.pull(eq(queue), eq("*"), eq(0L),
eq(32))).thenReturn(pullResult);
+
+ DLQExcelExportResultVO exported =
+ provider.exportExcel("instance-a", "group-a", 100L, 200L,
null);
+ assertThat(exported.getData()).isNotEmpty();
+
+ List<Map<Integer, String>> rows;
+ try (ByteArrayInputStream input = new
ByteArrayInputStream(exported.getData())) {
+ rows = EasyExcel.read(input).sheet("DLQ").doReadSync();
+ }
+ assertThat(rows).hasSize(2);
+ int bodyColumn = 7; // Message ID, Topic, Queue ID, Offset, Store
Time, Reconsume Times, Keys, Body
+ String oversizedBody = rows.get(0).get(bodyColumn);
+ assertThat(oversizedBody.length()).isLessThanOrEqualTo(32_767);
+ assertThat(oversizedBody).endsWith("...[truncated]");
+ // The cut must not split a surrogate pair into an unpaired half.
+ assertThat(oversizedBody.codePoints())
+ .noneMatch(codePoint -> codePoint >= 0xD800 && codePoint <=
0xDFFF);
+ assertThat(rows.get(1).get(bodyColumn)).isEqualTo("hello dlq");
+ }
+
private void stubExistingTarget(String topic) throws Exception {
TopicList existingTargets = new TopicList();
existingTargets.setTopicList(Set.of(topic));