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";

Reply via email to