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 20f797822 fix(dlq): preserve FIFO queue affinity during replay (#5192)
20f797822 is described below
commit 20f797822b9949c76ee1e8c6c98df852c4ee1fab
Author: zmuxuny <[email protected]>
AuthorDate: Mon Oct 19 03:54:34 2026 -0700
fix(dlq): preserve FIFO queue affinity during replay (#5192)
Verified against `rocketmq-studio` at f81e73a49 before merging:
缺陷仍在(`server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java:645`,`resendOne`(:614)
仍一律 `producer.send(message)`,全文无
merge-tree clean against the current trunk. Merged as-is (squash) per the
maintainer review of 2026-10-19.
---
.../provider/apache/RocketMQDLQProvider.java | 8 +-
.../provider/apache/RocketMQDLQProviderTest.java | 153 ++++++++++++++++++++-
2 files changed, 159 insertions(+), 2 deletions(-)
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 12a3a5251..c440a4590 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
@@ -642,7 +642,13 @@ public class RocketMQDLQProvider implements DLQProvider {
}
message.putUserProperty(ORIGIN_MESSAGE_ID_PROPERTY,
deadLetter.getMsgId());
message.putUserProperty(ORIGIN_TOPIC_PROPERTY,
deadLetter.getTopic());
- SendResult sendResult = producer.send(message);
+ // FIFO group metadata alone does not influence the ordinary
producer's queue selection.
+ // Keep one destination queue per group, matching the grouped
message-send path.
+ String messageGroup =
message.getProperty(MessageConst.PROPERTY_SHARDING_KEY);
+ SendResult sendResult = StringUtils.hasText(messageGroup)
+ ? producer.send(message, (queues, msg, group) ->
+ queues.get(Math.floorMod(group.hashCode(),
queues.size())), messageGroup)
+ : producer.send(message);
if (sendResult == null || sendResult.getSendStatus() !=
SendStatus.SEND_OK) {
log.warn("DLQ resend was not accepted: msgId={} topic={}
sendStatus={}",
deadLetter.getMsgId(), destination,
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 6adc6c4ca..02b1ab36a 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
@@ -21,7 +21,9 @@ import
org.apache.rocketmq.client.consumer.DefaultMQPullConsumer;
import org.apache.rocketmq.client.consumer.PullResult;
import org.apache.rocketmq.client.consumer.PullStatus;
import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.client.impl.producer.TopicPublishInfo;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
+import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.common.MixAll;
@@ -53,9 +55,9 @@ import org.apache.rocketmq.tools.admin.MQAdminExt;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
-import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -66,6 +68,7 @@ import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
+import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
@@ -1152,6 +1155,154 @@ class RocketMQDLQProviderTest {
.doesNotContainEntry(MessageConst.PROPERTY_REAL_TOPIC,
"system-topic-should-not-copy");
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void resendMessagesKeepsEachFifoGroupOnOneQueueTest(boolean selected)
throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ String destination = selected ? "orders" : "replay-orders";
+ List<String> groups = List.of("order-b", "polygenelubricants",
"order-b", "polygenelubricants");
+ List<MessageExt> deadLetters = IntStream.range(0,
groups.size()).mapToObj(index -> {
+ MessageExt deadLetter = new MessageExt();
+ deadLetter.setMsgId("fifo-" + index);
+ deadLetter.setTopic(dlqTopic);
+ deadLetter.setBody(new byte[] {(byte) index});
+ deadLetter.setStoreTimestamp(150L);
+ MessageAccessor.putProperty(deadLetter,
MessageConst.PROPERTY_SHARDING_KEY, groups.get(index));
+ MessageAccessor.putProperty(deadLetter,
MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC, "orders");
+ return deadLetter;
+ }).toList();
+ List<MessageQueue> queues = List.of(new MessageQueue(destination,
"broker-a", 0),
+ new MessageQueue(destination, "broker-a", 1), new
MessageQueue(destination, "broker-a", 2));
+ TopicPublishInfo publishInfo = new TopicPublishInfo();
+ publishInfo.setMessageQueueList(queues);
+ List<MessageQueue> sentQueues = new ArrayList<>();
+ List<Message> sentMessages = new ArrayList<>();
+ SendResult success = new SendResult();
+ success.setSendStatus(SendStatus.SEND_OK);
+ // Exercise the SDK's ordinary round-robin choice if replay takes the
ungrouped path.
+ lenient().when(dlqProducer.send(any(Message.class))).thenAnswer(call
-> {
+ sentMessages.add(call.getArgument(0));
+ sentQueues.add(publishInfo.selectOneMessageQueue());
+ return success;
+ });
+ lenient().when(dlqProducer.send(any(Message.class),
any(MessageQueueSelector.class), any()))
+ .thenAnswer(call -> {
+ Message message = call.getArgument(0);
+ MessageQueueSelector selector = call.getArgument(1);
+ Object group = call.getArgument(2);
+
assertThat(group).isEqualTo(message.getProperty(MessageConst.PROPERTY_SHARDING_KEY));
+ sentMessages.add(message);
+ sentQueues.add(selector.select(queues, message, group));
+ return success;
+ });
+ DLQResendResultVO result;
+ if (selected) {
+ for (MessageExt deadLetter : deadLetters) {
+ when(adminExt.viewMessage(dlqTopic,
deadLetter.getMsgId())).thenReturn(deadLetter);
+ }
+ result = provider.resendMessages("instance-a", "group-a",
+ deadLetters.stream().map(MessageExt::getMsgId).toList(),
null);
+ } else {
+ MessageQueue sourceQueue = new MessageQueue(dlqTopic, "broker-a",
0);
+
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(sourceQueue));
+ when(pullConsumer.searchOffset(sourceQueue, 100L)).thenReturn(0L);
+ when(pullConsumer.searchOffset(sourceQueue, 201L)).thenReturn(4L);
+ when(pullConsumer.pull(sourceQueue, "*", 0L, 32))
+ .thenReturn(new PullResult(PullStatus.FOUND, 4L, 0L, 4L,
deadLetters));
+ stubExistingTarget(destination);
+ result = provider.resendMessages("instance-a", "group-a", 100L,
200L, destination);
+ }
+
+ assertThat(result).extracting("matched", "resent", "failed", "outcome")
+ .containsExactly(4, 4, 0, "SUCCESS");
+ assertThat(sentMessages).extracting(message ->
message.getProperty(MessageConst.PROPERTY_SHARDING_KEY))
+ .containsExactlyElementsOf(groups);
+ assertThat(sentQueues).hasSize(4);
+ assertThat(sentQueues.get(0)).isEqualTo(sentQueues.get(2));
+ assertThat(sentQueues.get(1)).isEqualTo(sentQueues.get(3));
+ assertThat(sentQueues.get(0)).isNotEqualTo(sentQueues.get(1));
+ for (int index = 0; index < groups.size(); index++) {
+ assertThat(sentQueues.get(index))
+
.isEqualTo(queues.get(Math.floorMod(groups.get(index).hashCode(),
queues.size())));
+
assertThat(sentMessages.get(index).getTopic()).isEqualTo(destination);
+
assertThat(sentMessages.get(index).getBody()).containsExactly((byte) index);
+ }
+ verify(dlqProducer, never()).send(any(Message.class));
+ }
+
+ @Test
+ void resendMessagesWithoutFifoGroupKeepOrdinarySendTest() throws Exception
{
+ MessageExt deadLetter = fifoDeadLetter("plain-msg", null);
+ SendResult success = new SendResult();
+ success.setSendStatus(SendStatus.SEND_OK);
+ when(adminExt.viewMessage(deadLetter.getTopic(),
deadLetter.getMsgId())).thenReturn(deadLetter);
+ when(dlqProducer.send(any(Message.class))).thenReturn(success);
+
+ DLQResendResultVO result = provider.resendMessages(
+ "instance-a", "group-a", List.of(deadLetter.getMsgId()), null);
+
+ assertThat(result).extracting("resent", "failed",
"outcome").containsExactly(1, 0, "SUCCESS");
+ verify(dlqProducer).send(any(Message.class));
+ verify(dlqProducer, never()).send(any(Message.class),
any(MessageQueueSelector.class), any());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", " "})
+ void resendMessagesKeepsRejectingBlankGroupPropertiesTest(String group)
throws Exception {
+ MessageExt deadLetter = fifoDeadLetter("blank-group", group);
+ when(adminExt.viewMessage(deadLetter.getTopic(),
deadLetter.getMsgId())).thenReturn(deadLetter);
+
+ DLQResendResultVO result = provider.resendMessages(
+ "instance-a", "group-a", List.of(deadLetter.getMsgId()), null);
+
+ // The SDK's user-property validation already rejects these values
before either send path.
+ assertThat(result).extracting("resent", "failed",
"outcome").containsExactly(0, 1, "FAILED");
+ assertThat(result.getFailures()).singleElement().satisfies(failure ->
+ assertThat(failure.getReason()).contains("property can not be
null or blank"));
+ verifyNoInteractions(dlqProducer);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void failedFifoResendDoesNotFallBackToOrdinarySendTest(boolean
throwsException) throws Exception {
+ MessageExt deadLetter = fifoDeadLetter("failed-fifo", "order-42");
+ when(adminExt.viewMessage(deadLetter.getTopic(),
deadLetter.getMsgId())).thenReturn(deadLetter);
+ if (throwsException) {
+ when(dlqProducer.send(any(Message.class),
any(MessageQueueSelector.class), eq("order-42")))
+ .thenThrow(new IllegalStateException("broker
unavailable"));
+ } else {
+ SendResult rejected = new SendResult();
+ rejected.setSendStatus(SendStatus.FLUSH_DISK_TIMEOUT);
+ when(dlqProducer.send(any(Message.class),
any(MessageQueueSelector.class), eq("order-42")))
+ .thenReturn(rejected);
+ }
+
+ DLQResendResultVO result = provider.resendMessages(
+ "instance-a", "group-a", List.of(deadLetter.getMsgId()), null);
+
+ assertThat(result).extracting("matched", "resent", "failed", "outcome")
+ .containsExactly(1, 0, 1, "FAILED");
+ assertThat(result.getFailures()).singleElement().satisfies(failure -> {
+ assertThat(failure.getMsgId()).isEqualTo("failed-fifo");
+ assertThat(failure.getTargetTopic()).isEqualTo("orders");
+ assertThat(failure.getReason())
+ .contains(throwsException ? "broker unavailable" :
"FLUSH_DISK_TIMEOUT");
+ });
+ verify(dlqProducer, never()).send(any(Message.class));
+ }
+
+ private static MessageExt fifoDeadLetter(String messageId, String group) {
+ MessageExt deadLetter = new MessageExt();
+ deadLetter.setMsgId(messageId);
+ deadLetter.setTopic(MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a");
+ deadLetter.setBody(new byte[] {1});
+ MessageAccessor.putProperty(deadLetter,
MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC, "orders");
+ if (group != null) {
+ MessageAccessor.putProperty(deadLetter,
MessageConst.PROPERTY_SHARDING_KEY, group);
+ }
+ return deadLetter;
+ }
+
@Test
void resendSelectedMessagesLooksUpIdsWithoutAStoreTimeWindow() throws
Exception {
String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";