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 57b93490d feat(tools): expose bounded DLQ resend failure details 
(#7278)
57b93490d is described below

commit 57b93490da2558c906c8aa9b43c9f7a797e4b149
Author: pilichoumao <[email protected]>
AuthorDate: Mon Oct 26 16:37:14 2026 +0800

    feat(tools): expose bounded DLQ resend failure details (#7278)
    
    * feat(tools): expose bounded DLQ resend failure details
    * docs(tools): place DLQ output notes after resend constraints
    * docs(tools): preserve existing DLQ paragraph spacing
---
 docs/api-spec.md                                   |  6 ++
 rmqctl/cmd/catalog_renderers_test.go               | 65 ++++++++++++++++++++++
 rmqctl/internal/catalog/catalog_gen.go             |  2 +-
 .../message/MessageRedeliveryDlqOutput.java        | 20 ++++++-
 .../main/resources/tool-catalog/tools/message.yaml | 22 ++++++++
 .../MessageRedeliveryDlqToolHandlerTest.java       | 58 +++++++++++++++++++
 .../tool/service/ToolOutputSchemaContractTest.java | 36 +++++++++++-
 7 files changed, 204 insertions(+), 5 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index 05e137883..0c5c7ec73 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -2075,6 +2075,12 @@ Topic,未指定 `targetTopic` 时即原 Topic)、`reason`(`string`,归
 > 推断目的地(即其原 Topic),对推断出的每一个目的地跑同一次校验,任一跨集群即整批 `409` 拒绝,
 > 不会出现「重投了一半」。§9.4 的按 `msgIds` 重投走同一套校验。
 
+AI / CLI 的 `rmq.message.redelivery_dlq` 工具在执行结果中提供相同的有界 `failures` 明细和
+`failuresTruncated` 标记:最多保留 100 条,`failed` 仍为失败总数;`scanIncomplete` 只描述扫描完整性。
+无法解析目的 Topic 时,明细省略 `targetTopic`。新服务总是返回这两个字段(无失败时为 `[]` / `false`),
+它们在工具 schema 中为可选项,以兼容仅返回聚合字段的旧服务。rmqctl 默认摘要以及 JSON / YAML 输出均保留明细,
+不会自动重试。
+
 ### 9.3 分页获取死信消息明细
 
 ```
diff --git a/rmqctl/cmd/catalog_renderers_test.go 
b/rmqctl/cmd/catalog_renderers_test.go
index e4dcbda6b..83c064d5b 100644
--- a/rmqctl/cmd/catalog_renderers_test.go
+++ b/rmqctl/cmd/catalog_renderers_test.go
@@ -24,6 +24,7 @@ import (
        "io"
        "net/http"
        "net/http/httptest"
+       "strings"
        "sync/atomic"
        "testing"
 
@@ -234,3 +235,67 @@ func executeCatalogResult(t *testing.T, tool string, 
payload any, args ...string
        }
        return stdout, stderr, exitCode
 }
+
+func TestDlqResendFailureDetailsOutputTest(t *testing.T) {
+       for _, legacy := range []bool{false, true} {
+               for _, format := range []string{"table", "json", "yaml"} {
+                       t.Run(fmt.Sprintf("legacy=%t/%s", legacy, format), 
func(t *testing.T) {
+                               result := map[string]any{
+                                       "matched": 101, "resent": 0, "failed": 
101, "outcome": "FAILED",
+                                       "scanIncomplete": false, 
"failedQueueCount": 0,
+                               }
+                               if !legacy {
+                                       failures := make([]any, 100)
+                                       for i := range failures {
+                                               failure := 
map[string]any{"msgId": fmt.Sprintf("msg-%d", i), "reason": "broker 
unavailable"}
+                                               if i > 0 {
+                                                       failure["targetTopic"] 
= "orders"
+                                               }
+                                               failures[i] = failure
+                                       }
+                                       result["failures"] = failures
+                                       result["failuresTruncated"] = true
+                               }
+                               payload := map[string]any{
+                                       "status": "EXECUTED", "instanceId": 
"instance-dev",
+                                       "plan": map[string]any{"summary": 
"Resend dead letters"}, "result": result,
+                               }
+                               args := []string{"message", "redelivery-dlq", 
"--group-name", "group-1",
+                                       "--confirm-token", "confirmation", 
"--yes", "--output", format}
+                               stdout, stderr, exitCode := 
executeCatalogResult(t, "rmq.message.redelivery_dlq", payload, args...)
+                               if exitCode != 0 || stderr != "" {
+                                       t.Fatalf("exit = %d, stderr = %q", 
exitCode, stderr)
+                               }
+                               var got any
+                               var err error
+                               want := any(payload)
+                               if format == "table" {
+                                       _, body, found := strings.Cut(stdout, 
"\n\n")
+                                       if !found || !strings.Contains(stdout, 
"EXECUTED") {
+                                               t.Fatalf("missing mutation 
summary: %q", stdout)
+                                       }
+                                       err = json.Unmarshal([]byte(body), &got)
+                                       want = result
+                               } else if format == "yaml" {
+                                       err = yaml.Unmarshal([]byte(stdout), 
&got)
+                               } else {
+                                       err = json.Unmarshal([]byte(stdout), 
&got)
+                               }
+                               if err != nil {
+                                       t.Fatalf("decode %s output: %v", 
format, err)
+                               }
+                               gotJSON, err := json.Marshal(got)
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               wantJSON, err := json.Marshal(want)
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               if !bytes.Equal(gotJSON, wantJSON) {
+                                       t.Fatalf("failure details changed: got 
%s, want %s", gotJSON, wantJSON)
+                               }
+                       })
+               }
+       }
+}
diff --git a/rmqctl/internal/catalog/catalog_gen.go 
b/rmqctl/internal/catalog/catalog_gen.go
index ea1dbc8d9..39871e7fb 100644
--- a/rmqctl/internal/catalog/catalog_gen.go
+++ b/rmqctl/internal/catalog/catalog_gen.go
@@ -21,7 +21,7 @@ package catalog
 var defaultDocument = Document{
        Version:              "2.0.0",
        MinimumClientVersion: "2.0.0",
-       Digest:               
"e07c4c4a447545da57697f9e333a717c4bff6b660cefdaf6acfe4c1074791b39",
+       Digest:               
"de70ac57695e5fb9db7b1a16d995c745eaa89c1e33f339954a9fb7831e7e9fd7",
        Tools: []Tool{
                {
                        Name:                 "rmq.acl.list",
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageRedeliveryDlqOutput.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageRedeliveryDlqOutput.java
index b85d490d7..7a52e33a8 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageRedeliveryDlqOutput.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageRedeliveryDlqOutput.java
@@ -16,15 +16,21 @@
  */
 package org.apache.rocketmq.studio.ops.ai.tool.contract.message;
 
+import com.fasterxml.jackson.annotation.JsonInclude;
+import org.apache.rocketmq.studio.instance.dlq.DLQResendFailureVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQResendResultVO;
 
+import java.util.List;
+
 public record MessageRedeliveryDlqOutput(
         int matched,
         int resent,
         int failed,
         String outcome,
         boolean scanIncomplete,
-        int failedQueueCount) {
+        int failedQueueCount,
+        List<Failure> failures,
+        boolean failuresTruncated) {
 
     public static MessageRedeliveryDlqOutput from(DLQResendResultVO result) {
         return new MessageRedeliveryDlqOutput(
@@ -33,6 +39,16 @@ public record MessageRedeliveryDlqOutput(
                 result.getFailed(),
                 result.getOutcome(),
                 result.isScanIncomplete(),
-                result.getFailedQueueCount());
+                result.getFailedQueueCount(),
+                result.getFailures().stream().map(Failure::from).toList(),
+                result.isFailuresTruncated());
+    }
+
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public record Failure(String msgId, String targetTopic, String reason) {
+
+        public static Failure from(DLQResendFailureVO failure) {
+            return new Failure(failure.getMsgId(), failure.getTargetTopic(), 
failure.getReason());
+        }
     }
 }
diff --git a/server/src/main/resources/tool-catalog/tools/message.yaml 
b/server/src/main/resources/tool-catalog/tools/message.yaml
index 2da865a26..52fa0f448 100644
--- a/server/src/main/resources/tool-catalog/tools/message.yaml
+++ b/server/src/main/resources/tool-catalog/tools/message.yaml
@@ -732,5 +732,27 @@ tools:
               type: boolean
             failedQueueCount:
               type: integer
+            failures:
+              type: array
+              description: At most 100 per-message failures, with the same 
bounded reasons as the REST resend response.
+              maxItems: 100
+              items:
+                type: object
+                required:
+                  - msgId
+                  - reason
+                additionalProperties: false
+                properties:
+                  msgId:
+                    type: string
+                  targetTopic:
+                    type: string
+                    description: Omitted when no destination could be resolved.
+                  reason:
+                    type: string
+                    maxLength: 256
+            failuresTruncated:
+              type: boolean
+              description: True when more failures occurred than the retained 
details; independent of scanIncomplete.
     viewHint: object
     deprecated: false
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryDlqToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryDlqToolHandlerTest.java
index 1590cf48e..d345b9f65 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryDlqToolHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/message/MessageRedeliveryDlqToolHandlerTest.java
@@ -18,6 +18,7 @@ package 
org.apache.rocketmq.studio.ops.ai.tool.handler.message;
 
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.instance.dlq.DLQResendResultVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQResendFailureVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQService;
 import org.apache.rocketmq.studio.ops.ai.tool.contract.common.TimeRange;
 import 
org.apache.rocketmq.studio.ops.ai.tool.contract.message.MessageRedeliveryDlqInput;
@@ -35,6 +36,8 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Collections;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -92,11 +95,66 @@ class MessageRedeliveryDlqToolHandlerTest {
         MessageRedeliveryDlqInput input = new 
MessageRedeliveryDlqInput("instance-a", "group-1", null, null);
         MessageRedeliveryDlqOutput output = handler.execute(input, 
context("instance-a"));
         assertThat(output.matched()).isZero();
+        assertThat(output.failures()).isEmpty();
+        assertThat(output.failuresTruncated()).isFalse();
         verify(dlqService).resendMessages(eq("instance-a"), eq("group-1"),
                 eq(null), eq(null), eq(null));
         verifyNoMoreInteractions(dlqService);
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void executePreservesFailureDetailsAndTheirTruncationTest(boolean 
truncated) {
+        DLQResendFailureVO failure = DLQResendFailureVO.builder()
+                .msgId("failed-1").targetTopic("orders").reason("Producer send 
failed: broker unavailable").build();
+        List<DLQResendFailureVO> failures = truncated ? 
Collections.nCopies(100, failure) : List.of(failure);
+        int failed = truncated ? 101 : 1;
+        when(dlqService.resendMessages("instance-a", "group-1", null, null, 
null))
+                
.thenReturn(DLQResendResultVO.builder().matched(failed).failed(failed).outcome("FAILED")
+                        
.failures(failures).failuresTruncated(truncated).build());
+
+        MessageRedeliveryDlqOutput output = handler.execute(
+                new MessageRedeliveryDlqInput("instance-a", "group-1", null, 
null), context("instance-a"));
+
+        assertThat(output.matched()).isEqualTo(failed);
+        assertThat(output.resent()).isZero();
+        assertThat(output.failed()).isEqualTo(failed);
+        assertThat(output.outcome()).isEqualTo("FAILED");
+        assertThat(output.scanIncomplete()).isFalse();
+        assertThat(output.failedQueueCount()).isZero();
+        assertThat(output.failures()).hasSize(failures.size()).allSatisfy(item 
-> {
+            assertThat(item.msgId()).isEqualTo("failed-1");
+            assertThat(item.targetTopic()).isEqualTo("orders");
+            assertThat(item.reason()).isEqualTo("Producer send failed: broker 
unavailable");
+        });
+        assertThat(output.failuresTruncated()).isEqualTo(truncated);
+        verify(dlqService).resendMessages("instance-a", "group-1", null, null, 
null);
+        verifyNoMoreInteractions(dlqService);
+    }
+
+    @Test
+    void executeKeepsUnresolvedTargetAbsentTest() {
+        DLQResendFailureVO failure = DLQResendFailureVO.builder()
+                .msgId("failed-1").reason("No target topic could be 
resolved").build();
+        when(dlqService.resendMessages("instance-a", "group-1", null, null, 
null))
+                
.thenReturn(DLQResendResultVO.builder().matched(1).failed(1).outcome("FAILED")
+                        
.scanIncomplete(true).failedQueueCount(1).failures(List.of(failure)).build());
+
+        MessageRedeliveryDlqOutput output = handler.execute(
+                new MessageRedeliveryDlqInput("instance-a", "group-1", null, 
null), context("instance-a"));
+
+        assertThat(output.failures()).singleElement().satisfies(item -> {
+            assertThat(item.msgId()).isEqualTo("failed-1");
+            assertThat(item.targetTopic()).isNull();
+            assertThat(item.reason()).isEqualTo("No target topic could be 
resolved");
+        });
+        assertThat(output.scanIncomplete()).isTrue();
+        assertThat(output.failedQueueCount()).isOne();
+        assertThat(output.failuresTruncated()).isFalse();
+        verify(dlqService).resendMessages("instance-a", "group-1", null, null, 
null);
+        verifyNoMoreInteractions(dlqService);
+    }
+
     @Test
     void executeMapsMissingDlqToNotFoundTest() {
         when(dlqService.resendMessages(eq("instance-a"), eq("group-x"),
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
index 3377edf82..be0c9b259 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolOutputSchemaContractTest.java
@@ -71,12 +71,14 @@ import tools.jackson.databind.json.JsonMapper;
 
 import java.time.LocalDateTime;
 import java.util.LinkedHashMap;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * Golden output-contract sweep: one representative sample per catalog tool, 
validated through
@@ -112,6 +114,29 @@ class ToolOutputSchemaContractTest {
         assertThat(covered).isEqualTo(declared);
     }
 
+    @Test
+    void acceptsLegacyDlqResendOutputWithoutDetailsTest() {
+        
validator.validateOutput(catalog.getDefinition("rmq.message.redelivery_dlq"), 
executed(Map.of(
+                "matched", 5, "resent", 5, "failed", 0, "outcome", 
"ALL_RESENT",
+                "scanIncomplete", false, "failedQueueCount", 0)));
+    }
+
+    @Test
+    void rejectsOversizedOrUndeclaredDlqFailureDetailsTest() {
+        ToolDefinition definition = 
catalog.getDefinition("rmq.message.redelivery_dlq");
+        Map<String, Object> result = new LinkedHashMap<>(Map.of(
+                "matched", 101, "resent", 0, "failed", 101, "outcome", 
"FAILED",
+                "scanIncomplete", false, "failedQueueCount", 0, 
"failuresTruncated", true));
+        Map<String, Object> failure = new LinkedHashMap<>(Map.of("msgId", 
"MSG-1", "reason", "broker unavailable"));
+        result.put("failures", Collections.nCopies(101, failure));
+        assertThatThrownBy(() -> validator.validateOutput(definition, 
executed(result)))
+                .isInstanceOf(IllegalStateException.class);
+        failure.put("unexpected", "not part of the contract");
+        result.put("failures", List.of(failure));
+        assertThatThrownBy(() -> validator.validateOutput(definition, 
executed(result)))
+                .isInstanceOf(IllegalStateException.class);
+    }
+
     private static Map<String, List<Object>> samples() {
         Map<String, List<Object>> samples = new LinkedHashMap<>();
 
@@ -349,8 +374,15 @@ class ToolOutputSchemaContractTest {
                 new MessageSendOutput("MSG-1", "OFF-1", TIMESTAMP))));
         samples.put("rmq.message.redelivery", List.of(planned(), executed(
                 new MessageRedeliveryOutput("MSG-1", "MSG-2", 
"%RETRY%cg-orders"))));
-        samples.put("rmq.message.redelivery_dlq", List.of(planned(), executed(
-                new MessageRedeliveryDlqOutput(5, 5, 0, "ALL_RESENT", false, 
0))));
+        MessageRedeliveryDlqOutput.Failure failure = new 
MessageRedeliveryDlqOutput.Failure(
+                "MSG-1", "orders", "Producer send failed: broker unavailable");
+        samples.put("rmq.message.redelivery_dlq", List.of(planned(),
+                executed(new MessageRedeliveryDlqOutput(5, 5, 0, "ALL_RESENT", 
false, 0, List.of(), false)),
+                executed(new MessageRedeliveryDlqOutput(101, 0, 101, "FAILED", 
false, 0,
+                        Collections.nCopies(100, failure), true)),
+                executed(new MessageRedeliveryDlqOutput(1, 0, 1, "FAILED", 
true, 1,
+                        List.of(new MessageRedeliveryDlqOutput.Failure(
+                                "MSG-1", null, "No target topic could be 
resolved")), false))));
 
         return samples;
     }

Reply via email to