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 37fa6f8d7 fix(dlq): preserve Unicode boundaries in resend failures
(#7275)
37fa6f8d7 is described below
commit 37fa6f8d7c308e936de2dbd5fdb4412905e91c4c
Author: pilichoumao <[email protected]>
AuthorDate: Mon Oct 26 16:36:52 2026 +0800
fix(dlq): preserve Unicode boundaries in resend failures (#7275)
* fix(dlq): preserve Unicode boundaries in resend failures
---
docs/api-spec.md | 2 +-
.../provider/apache/RocketMQDLQProvider.java | 7 ++-
.../provider/apache/RocketMQDLQProviderTest.java | 65 ++++++++++++++++++++++
3 files changed, 72 insertions(+), 2 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index c883505b2..05e137883 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -2066,7 +2066,7 @@ POST /api/dlq/resend
| `failuresTruncated` | `boolean` | 失败明细是否被截断:失败条数超过 100 时为 `true`,此时 `failed`
仍是真实的失败总数 |
`DLQResendFailure` 的字段:`msgId`(`string`,重投失败的死信消息
ID)、`targetTopic`(`string`,解析出的目标
-Topic,未指定 `targetTopic` 时即原 Topic)、`reason`(`string`,归一化后的简短失败原因,最长 256 字符)。
+Topic,未指定 `targetTopic` 时即原 Topic)、`reason`(`string`,归一化后的简短失败原因,最长 256 个
UTF-16 单元;超长时在完整字符边界截断并以 `...` 结尾)。
§9.4「重发选中的死信消息」返回同一结构,两条重投路径共用该 VO。
> **跨集群拒绝(`409`)**:显式传入 `targetTopic` 时,若它解析出的集群不是死信 Topic(`%DLQ%{groupName}`)
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 d64ca19c6..c7cdc8444 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
@@ -708,7 +708,12 @@ public class RocketMQDLQProvider implements DLQProvider {
if (normalized.length() <= MAX_FAILURE_REASON_LENGTH) {
return normalized;
}
- return normalized.substring(0, MAX_FAILURE_REASON_LENGTH - 3) + "...";
+ int keep = MAX_FAILURE_REASON_LENGTH - 3;
+ // Preserve the UTF-16 response budget without keeping half a
supplementary character.
+ if (Character.isHighSurrogate(normalized.charAt(keep - 1))) {
+ keep--;
+ }
+ return normalized.substring(0, keep) + "...";
}
/**
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 02b1ab36a..ebc65cb2c 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
@@ -57,6 +57,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
@@ -88,6 +89,7 @@ import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -1382,6 +1384,69 @@ class RocketMQDLQProviderTest {
assertThat(result.isFailuresTruncated()).isFalse();
}
+ @ParameterizedTest
+ @CsvSource({
+ "false, 229, true", "false, 230, false", "false, 231, false",
+ "true, 226, true", "true, 227, false", "true, 228, false"
+ })
+ void resendFailuresPreserveUnicodeAtCutBoundaryTest(boolean
sessionFailure, int filler,
+ boolean keepsEmoji)
throws Exception {
+ String prefix = sessionFailure ? "Producer session failed: " :
"Producer send failed: ";
+ String error = "x".repeat(filler) + "😀" + "y".repeat(40);
+ DLQResendResultVO result = resendWithProducerError(error,
sessionFailure);
+
+ assertThat(result).extracting("matched", "resent", "failed", "outcome")
+ .containsExactly(1, 0, 1, "FAILED");
+ assertThat(result.getFailures()).singleElement().satisfies(failure -> {
+ String expected = prefix + "x".repeat(filler) + (keepsEmoji ? "😀"
: "") + "...";
+
assertThat(failure.getReason()).isEqualTo(expected).hasSizeLessThanOrEqualTo(256);
+ assertThat(new
String(failure.getReason().getBytes(StandardCharsets.UTF_8),
StandardCharsets.UTF_8))
+ .isEqualTo(failure.getReason());
+ assertThat(failure.getMsgId()).isEqualTo("selected-msg");
+ assertThat(failure.getTargetTopic()).isEqualTo("orders");
+ });
+ assertThat(result.isFailuresTruncated()).isFalse();
+ }
+
+ @ParameterizedTest
+ @CsvSource({"false", "true"})
+ void resendFailuresKeepShortUnicodeAndNormalizeWhitespaceTest(boolean
sessionFailure) throws Exception {
+ String prefix = sessionFailure ? "Producer session failed: " :
"Producer send failed: ";
+ DLQResendResultVO result = resendWithProducerError(" broker\n 😀
unavailable ", sessionFailure);
+
+ assertThat(result.getFailures()).singleElement().satisfies(failure ->
+ assertThat(failure.getReason()).isEqualTo(prefix + "broker 😀
unavailable"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({"false", "true"})
+ void resendFailuresKeepAsciiReasonBudgetTest(boolean sessionFailure)
throws Exception {
+ String prefix = sessionFailure ? "Producer session failed: " :
"Producer send failed: ";
+ DLQResendResultVO result = resendWithProducerError("x".repeat(300),
sessionFailure);
+
+ assertThat(result.getFailures()).singleElement().satisfies(failure ->
+ assertThat(failure.getReason())
+ .isEqualTo(prefix + "x".repeat(253 - prefix.length())
+ "...")
+ .hasSize(256));
+ }
+
+ private DLQResendResultVO resendWithProducerError(String error, boolean
sessionFailure) throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ MessageExt deadLetter = new MessageExt();
+ deadLetter.setMsgId("selected-msg");
+ deadLetter.setTopic(dlqTopic);
+ deadLetter.setBody(new byte[] {1});
+ MessageAccessor.putProperty(deadLetter,
MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC, "orders");
+ when(adminExt.viewMessage(dlqTopic,
"selected-msg")).thenReturn(deadLetter);
+ if (sessionFailure) {
+ doThrow(new
IllegalStateException(error)).when(runtimeAdminClientResolver)
+ .executeProducer(anyString(), any());
+ } else {
+ when(dlqProducer.send(any(Message.class))).thenThrow(new
IllegalStateException(error));
+ }
+ return provider.resendMessages("instance-a", "group-a",
List.of("selected-msg"), null);
+ }
+
@Test
void resendMessagesCapsReportedFailureDetailsTest() throws Exception {
String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";