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 aaf1d367c fix(studio): report unknown size and delay instead of zero
(#5649)
aaf1d367c is described below
commit aaf1d367c58af9d7508ebb28d726c25b79c601fe
Author: Apulupie <[email protected]>
AuthorDate: Tue Oct 27 23:44:04 2026 +0800
fix(studio): report unknown size and delay instead of zero (#5649)
* fix(message): report an unknown cloud message size instead of a
fabricated zero (#5649)
Three call sites rendered a message whose body size the vendor API never
reported
as 0 bytes: Aliyun's ListMessages returns a nullable Integer BodySize, and
Tencent's DescribeMessage / MessageItem models have no size getter at all
(verified against aliyun 5.0.8 and trocket 3.1.1498). An operator reading
"0 B" was told the message was empty. Carry MessageRecordVO.UNKNOWN_SIZE
(-1) -
the sentinel the repository already uses for QueueProgressVO.UNKNOWN_OFFSET
and
in web/src/utils/consumerLag.ts - and keep it out of every user-facing
surface:
format.ts gains UNKNOWN_MESSAGE_SIZE and formatOptionalBytes next to the
existing
formatOptionalTimeMs, wired into the message table, the detail drawer and
QueueBrowser; message.yaml declares minimum: -1 with a description on all
three
size properties, following group.yaml; and docs/api-spec.md states that -1
means
the vendor did not report a size. The AI contract records (MessageItem,
MessageQueryOutput, RedeliverySource) keep the sentinel in structured
output and
document it, the same split #5291 applied to the reset-offset plan:
structured
fields carry the sentinel, prose never does.
* fix(ai): stop publishing an unmeasurable consume delay as zero seconds
(#6106)
group.detail's delaySeconds was a primitive int, so a group whose delay
could not
be measured was published to the model as "0 seconds" - a fabricated fact
rather
than an absent one. Make it Integer and omit it when unknown, following
retryMaxTimes in the same record, which is already an Integer that is null
and
absent from required. The catalog is regenerated for both this and the
message.yaml
change above in a single pass; make catalog-verify reports no drift.
* fix(instance): surface the unique-name race as a duplicate-name error
(#5297)
Two concurrent creates can both pass requireUniqueInstanceName; the loser
hits the
uk_instance_name unique key and the catch in InstanceService only
translated the
cloud-credential foreign-key violation, rethrowing everything else into a
bare 500.
Walk the cause chain for that key and throw DuplicateInstanceNameException,
so the
raced path answers with the same 400 as the sequential one.
Two of these needed work to land on the current baseline. #5297's test
fixture
predates #7353, which made nameserverId mandatory on the APACHE create
path, so
the fixture now supplies it; the new test method also gained the
repository's Test
suffix and the java.sql.SQLException import moved into the java.* group.
#5649
conflicted with #7033 in TencentInstanceProvider (both kept, verified line
by
line) and in MessagePage.test.tsx, where this change and trunk each append
a test
to the same describe block - both are kept.
Batch integration of three related fixes by the same contributor; #5297 and
#6106
are closed as merged into this one. Verified on the combined branch after
rebasing
onto the current trunk: 4161 backend tests, 2019 frontend tests across 166
files,
the production build and type check, license gate 8/8, eslint with no new
warnings,
and make catalog-verify / go build / go test / go vet / make fmt all clean.
---
docs/api-spec.md | 2 +-
rmqctl/internal/catalog/catalog_gen.go | 2 +-
.../rocketmq/studio/instance/InstanceService.java | 18 +++++++++
.../studio/instance/message/MessageRecordVO.java | 10 +++++
.../ai/tool/contract/group/GroupDetailOutput.java | 9 ++++-
.../ops/ai/tool/contract/message/MessageItem.java | 1 +
.../tool/contract/message/MessageQueryOutput.java | 1 +
.../message/MessageRedeliveryToolHandler.java | 7 ++++
.../studio/provider/alibaba/AliyunConverters.java | 3 +-
.../provider/tencent/TencentInstanceProvider.java | 6 ++-
.../main/resources/tool-catalog/tools/group.yaml | 5 ++-
.../main/resources/tool-catalog/tools/message.yaml | 6 +++
.../studio/instance/InstanceServiceTest.java | 20 ++++++++++
.../group/ConsumerGroupReadToolHandlersTest.java | 43 ++++++++++++++++++++++
.../alibaba/AliyunConvertersBodyEncodingTest.java | 2 +
.../tencent/TencentInstanceProviderTest.java | 17 +++++++++
web/src/api/message.ts | 1 +
web/src/components/QueueBrowser.tsx | 4 +-
.../pages/instance/__tests__/MessagePage.test.tsx | 27 ++++++++++++++
web/src/pages/instance/message.tsx | 6 +--
web/src/utils/format.test.ts | 13 +++++++
web/src/utils/format.ts | 12 ++++++
22 files changed, 202 insertions(+), 13 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index 8f124bf49..a761e1f03 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -2085,7 +2085,7 @@ GET /api/messages
| `storeHost` | `string` | 存储 Broker 地址 |
| `properties` | `Record<string, string>` | 消息属性键值对 |
| `propertiesTruncated` | `boolean` | 消息属性是否被截断 |
-| `size` | `number` | 消息大小(字节) |
+| `size` | `number` | 消息大小(字节);`-1` 表示云厂商 API 未返回大小(未知,不是 0 字节),前端渲染为 `-` |
### 8.2 分页查询消息
diff --git a/rmqctl/internal/catalog/catalog_gen.go
b/rmqctl/internal/catalog/catalog_gen.go
index ffdc4f97a..39960494c 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:
"e2ebdf66500503301025ed12f41ada2176e63249dce4732ee0e98c57300be682",
+ Digest:
"3b4db152a6ca5e7dd92de7d4394d434b4b8448d14844198eb2fc2bc235e3d0c5",
Tools: []Tool{
{
Name: "rmq.acl.list",
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 633766836..06256316a 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -265,6 +265,12 @@ public class InstanceService {
try {
saved =
ownershipGuard.withInstanceRegistration(instance.getName(), () ->
instanceRepository.save(instance));
} catch (DataIntegrityViolationException exception) {
+ // Two concurrent creates can both pass requireUniqueInstanceName;
the loser hits the
+ // uk_instance_name unique key and must surface as the same
duplicate-name error as
+ // the sequential case instead of a raw 500.
+ if (isUniqueInstanceNameViolation(exception)) {
+ throw new DuplicateInstanceNameException(instance.getName());
+ }
if (vendor != InstanceVendor.APACHE &&
isCloudCredentialReferenceViolation(exception)) {
throw new BusinessException(409, "Cloud credential no longer
exists: " + instance.getCredentialId());
}
@@ -855,6 +861,18 @@ public class InstanceService {
return "name=" + instance.getName() + ", vendor=" + vendor + ", type="
+ instance.getType();
}
+ private boolean isUniqueInstanceNameViolation(Throwable exception) {
+ Throwable current = exception;
+ while (current != null) {
+ String message = current.getMessage();
+ if (message != null &&
message.toLowerCase(Locale.ROOT).contains("uk_instance_name")) {
+ return true;
+ }
+ current = current.getCause();
+ }
+ return false;
+ }
+
private boolean isCloudCredentialReferenceViolation(Throwable exception) {
Throwable current = exception;
while (current != null) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
index 8869f9b56..4e456d80b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
@@ -28,6 +28,16 @@ import java.util.Map;
@NoArgsConstructor
@AllArgsConstructor
public class MessageRecordVO {
+ /**
+ * Sentinel for a body size the vendor API does not report: Aliyun {@code
ListMessages} leaves
+ * {@code BodySize} null, and Tencent {@code DescribeMessage} / {@code
MessageItem} carry no size
+ * field at all. It matches the {@code -1} the broker uses for an
undeterminable value, the
+ * console renders a negative size as unavailable instead of a number that
would read like a
+ * measurement, and the tool catalog declares {@code minimum: -1} so an AI
caller learns the
+ * sentinel means unknown rather than zero bytes.
+ */
+ public static final int UNKNOWN_SIZE = -1;
+
private String msgId;
private String topic;
private String tag;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
index ca5216136..b334a8a37 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/group/GroupDetailOutput.java
@@ -47,7 +47,12 @@ public record GroupDetailOutput(
String subscriptionDataType,
String deliveryOrderType,
Integer retryMaxTimes,
- int delaySeconds,
+ /**
+ * Seconds since the group last consumed a message; null when the
broker reported no
+ * consumed-message timestamp, so no delay can be measured. Never a
zero the caller could
+ * read as "caught up".
+ */
+ Integer delaySeconds,
List<Subscription> subscriptions,
List<Instance> instances,
Health health,
@@ -82,7 +87,7 @@ public record GroupDetailOutput(
source.getSubscriptionDataType(),
source.getDeliveryOrderType(),
configurations.size() == 1 ? source.getRetryMaxTimes() : null,
- source.getDelaySeconds(),
+ source.isConsumptionTimestampAvailable() ?
source.getDelaySeconds() : null,
entries.stream()
.filter(Objects::nonNull)
.map(Subscription::from)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
index e3bca48a1..f06d8509c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageItem.java
@@ -31,6 +31,7 @@ public record MessageItem(
String body,
String bodyEncoding,
boolean bodyTruncated,
+ /** Body size in bytes, or -1 when the vendor API reports none
(unknown, not zero). */
int size) {
public static MessageItem from(MessageRecordVO message) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
index cb3c33063..56cf7dd73 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/contract/message/MessageQueryOutput.java
@@ -61,6 +61,7 @@ public record MessageQueryOutput(
String body,
String bodyEncoding,
Boolean bodyTruncated,
+ /** Body size in bytes, or -1 when the vendor API reports none
(unknown, not zero). */
int size) {
static Item from(MessageRecordVO message, boolean includeBody) {
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 b9001ae90..fc71506de 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
@@ -98,6 +98,13 @@ public class MessageRedeliveryToolHandler extends
MutationToolHandler<MessageRed
: RETRY_TOPIC_PREFIX + groupName;
}
+ /**
+ * The source message the plan snapshots. {@code size} carries
+ * {@link MessageRecordVO#UNKNOWN_SIZE} when the vendor API reports no
body size: structured
+ * output keeps the sentinel the way {@code
GroupResetOffsetToolHandler.QueueState} keeps its
+ * own, and the catalog declares the plan's {@code before} side as a
free-form object, so no
+ * per-property description travels with it.
+ */
private record RedeliverySource(String msgId, String topic, String tag,
String key, long storeTime, int size) {
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
index 96406534b..4dc43d0b8 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConverters.java
@@ -262,7 +262,8 @@ final class AliyunConverters {
.storeHost(data.getStoreHost())
.storeTime(parseTimeMillis(data.getStoreTime()))
.properties(data.getUserProperties())
- .size(data.getBodySize() == null ? 0 : data.getBodySize());
+ // BodySize is optional in the ListMessages response; report
the unknown sentinel instead of a fabricated zero.
+ .size(data.getBodySize() == null ?
MessageRecordVO.UNKNOWN_SIZE : data.getBodySize());
if (body != null) {
builder.body(body.value()).bodyEncoding(body.encoding());
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
index 5b6de1d07..7b2f0093d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
@@ -858,7 +858,8 @@ public class TencentInstanceProvider implements
InstanceProvider {
.bornHost(response.getProducerAddr())
.properties(properties)
.propertiesTruncated(false)
- .size(0)
+ // DescribeMessage/MessageItem carry no body size; report the
unknown sentinel instead of a fabricated zero.
+ .size(MessageRecordVO.UNKNOWN_SIZE)
.build();
}
@@ -878,7 +879,8 @@ public class TencentInstanceProvider implements
InstanceProvider {
.bornHost(item.getProducerAddr())
.properties(Collections.emptyMap())
.propertiesTruncated(false)
- .size(0)
+ // DescribeMessage/MessageItem carry no body size; report the
unknown sentinel instead of a fabricated zero.
+ .size(MessageRecordVO.UNKNOWN_SIZE)
.build();
}
diff --git a/server/src/main/resources/tool-catalog/tools/group.yaml
b/server/src/main/resources/tool-catalog/tools/group.yaml
index 3ac090089..6c7bf36d4 100644
--- a/server/src/main/resources/tool-catalog/tools/group.yaml
+++ b/server/src/main/resources/tool-catalog/tools/group.yaml
@@ -104,7 +104,6 @@ tools:
- totalLag
- subscribedTopics
- subscriptions
- - delaySeconds
- health
additionalProperties: false
properties:
@@ -143,6 +142,10 @@ tools:
delaySeconds:
type: integer
minimum: 0
+ description: >-
+ Seconds since the group last consumed a message. Absent when the
broker reported no
+ consumed-message timestamp, so the delay cannot be measured - the
value is not a
+ measured zero in that case.
subscriptions:
type: array
items:
diff --git a/server/src/main/resources/tool-catalog/tools/message.yaml
b/server/src/main/resources/tool-catalog/tools/message.yaml
index c43e04b15..aa14d3bdb 100644
--- a/server/src/main/resources/tool-catalog/tools/message.yaml
+++ b/server/src/main/resources/tool-catalog/tools/message.yaml
@@ -89,6 +89,8 @@ tools:
type: boolean
size:
type: integer
+ minimum: -1
+ description: Message body size in bytes; -1 when the vendor
API reports none (unknown, not zero).
resultMayBeTruncated:
type: boolean
description: True when rows were omitted by limit or the provider
may have stopped at its own scan bound.
@@ -174,6 +176,8 @@ tools:
type: boolean
size:
type: integer
+ minimum: -1
+ description: Message body size in bytes; -1 when the vendor
API reports none (unknown, not zero).
resultMayBeTruncated:
type: boolean
description: True when rows were omitted by limit or the provider
may have stopped at its own scan bound.
@@ -257,6 +261,8 @@ tools:
type: boolean
size:
type: integer
+ minimum: -1
+ description: Message body size in bytes; -1 when the vendor
API reports none (unknown, not zero).
viewHint: table
deprecated: false
- name: rmq.message.query_dlq
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index b1b041cdf..8a2551979 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -47,6 +47,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
+import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
@@ -577,6 +578,25 @@ class InstanceServiceTest {
.hasMessage("InstanceVO name is required");
}
+ @Test
+ void createInstanceTranslatesUniqueKeyViolationToDuplicateNameTest() {
+ // Two concurrent creates can both pass the pre-check; the loser hits
uk_instance_name and
+ // must surface as the duplicate-name error, not a raw 500.
+ InstanceVO input = InstanceVO.builder()
+ .name("racing-name")
+ .endpoint("10.0.1.1:8080")
+ .type(InstanceType.PROXY_CLUSTER)
+ .nameserverId(1L).build();
+ DataIntegrityViolationException violation = new
DataIntegrityViolationException(
+ "could not execute statement",
+ new SQLException("Duplicate entry 'racing-name' for key
'uk_instance_name'"));
+
when(instanceRepository.save(any(InstanceVO.class))).thenThrow(violation);
+
+ assertThatThrownBy(() -> instanceService.createInstance(input))
+ .isInstanceOf(DuplicateInstanceNameException.class)
+ .hasMessage("Instance name already exists: racing-name");
+ }
+
@Test
void createInstanceShouldThrowWhenNameIsBlank() {
InstanceVO input = InstanceVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
index 1f7eedabd..faaf9061e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/handler/group/ConsumerGroupReadToolHandlersTest.java
@@ -288,6 +288,49 @@ class ConsumerGroupReadToolHandlersTest {
assertThat(output.instances()).isEmpty();
}
+ /**
+ * The provider leaves {@code delaySeconds} at its zero default when it
cannot compute a delay and
+ * records that fact in {@code consumptionTimestampAvailable}. Publishing
the zero tells the model
+ * the group is fully caught up, so an unmeasurable delay must not carry a
number at all - the
+ * sibling lag field publishes its -1 sentinel and the health ladder
answers UNKNOWN for the same
+ * class of "unknown collapsed into a measurement".
+ */
+ @Test
+ void detailOmitsTheDelayWhenNoConsumedMessageTimestampIsAvailableTest() {
+ group.setOnlineInstances(1);
+ group.setTotalLag(0L);
+ group.setConsumptionTimestampAvailable(false);
+ group.setDelaySeconds(0);
+ when(metadataService.consumerGroupRuntimeView("instance-a",
"group-a")).thenReturn(group);
+ when(metadataService.consumerGroupConfigurations("instance-a",
"group-a")).thenReturn(List.of(group));
+ when(metadataService.getGroupSubscriptions("instance-a",
"group-a")).thenReturn(List.of());
+ when(metadataService.getGroupProgress("instance-a",
"group-a")).thenReturn(List.of());
+
+ GroupDetailOutput output = new GroupDetailToolHandler(metadataService)
+ .execute(new GroupDetailInput("instance-a", "group-a", null),
context());
+ JsonNode detail = new
LegacyJackson2Config().jackson2ObjectMapper().valueToTree(output);
+
+ assertThat(detail.has("delaySeconds")).isFalse();
+ }
+
+ @Test
+ void detailKeepsTheDelayWhenTheConsumedMessageTimestampIsAvailableTest() {
+ group.setOnlineInstances(1);
+ group.setTotalLag(0L);
+ group.setConsumptionTimestampAvailable(true);
+ group.setDelaySeconds(42);
+ when(metadataService.consumerGroupRuntimeView("instance-a",
"group-a")).thenReturn(group);
+ when(metadataService.consumerGroupConfigurations("instance-a",
"group-a")).thenReturn(List.of(group));
+ when(metadataService.getGroupSubscriptions("instance-a",
"group-a")).thenReturn(List.of());
+ when(metadataService.getGroupProgress("instance-a",
"group-a")).thenReturn(List.of());
+
+ GroupDetailOutput output = new GroupDetailToolHandler(metadataService)
+ .execute(new GroupDetailInput("instance-a", "group-a", null),
context());
+ JsonNode detail = new
LegacyJackson2Config().jackson2ObjectMapper().valueToTree(output);
+
+ assertThat(detail.path("delaySeconds").asInt()).isEqualTo(42);
+ }
+
private static ToolExecutionContext context() {
return ToolExecutionContext.of(
"instance-a", null, Map.of("instanceId", "instance-a"));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
index be7145463..203541fe7 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/alibaba/AliyunConvertersBodyEncodingTest.java
@@ -49,6 +49,8 @@ class AliyunConvertersBodyEncodingTest {
assertThat(record.getBody()).isEqualTo("{\"orderId\":42}");
assertThat(record.getBodyEncoding()).isEqualTo("UTF-8");
+ // ListMessages without BodySize reports the unknown sentinel, not a
fabricated zero.
+ assertThat(record.getSize()).isEqualTo(MessageRecordVO.UNKNOWN_SIZE);
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
index 656da42a7..7c0a0d48f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
@@ -1014,6 +1014,23 @@ class TencentInstanceProviderTest {
assertThat(captor.getValue().getMsgId()).isEqualTo("MSG-1");
}
+ @Test
+ void queryMessagesByMsgIdShouldReportUnknownSizeTest() throws Exception {
+ DescribeMessageResponse detail = new DescribeMessageResponse();
+ detail.setMessageId("MSG-1");
+ detail.setShowTopicName("orders");
+ detail.setBody("hello body");
+ detail.setProduceTime("2024-09-12 14:06:55,591");
+ when(client.DescribeMessage(any())).thenReturn(detail);
+
+ List<MessageRecordVO> messages =
+ provider.queryMessages(STUDIO_INSTANCE_ID, "orders", "MSG-1",
null, null, null, null);
+
+ // DescribeMessage carries no body size; the record must not claim a
fabricated zero.
+ assertThat(messages).singleElement()
+ .satisfies(record ->
assertThat(record.getSize()).isEqualTo(MessageRecordVO.UNKNOWN_SIZE));
+ }
+
@Test
void getMessageTraceShouldPreserveOffsetsForAllTimestampedStagesTest()
throws Exception {
MessageTraceItem produce = new MessageTraceItem();
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index b1d7dd2b7..39205d872 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -20,6 +20,7 @@ export interface MessageRecord {
reconsumeTimes?: number;
properties: Record<string, string>;
propertiesTruncated?: boolean;
+ /** Body size in bytes; -1 when the vendor API does not report one (unknown,
not zero). */
size: number;
}
diff --git a/web/src/components/QueueBrowser.tsx
b/web/src/components/QueueBrowser.tsx
index 7a583292b..87deed2ab 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -36,7 +36,7 @@ import type { MessageRecord, QueueOffset } from
'../api/message';
import { getQueueOffsets, pullMessageAtOffset } from '../api/message';
import { useLang } from '../i18n/LangContext';
import { tableScrollX } from '../utils/table';
-import { formatBytes, formatTimeMs } from '../utils/format';
+import { formatOptionalBytes, formatTimeMs } from '../utils/format';
const { Text } = Typography;
@@ -372,7 +372,7 @@ export const QueueBrowserResults = ({ state, onOpenMessage
}: QueueBrowserResult
align: 'right',
render: (_: unknown, entry: PulledEntry) => (
<span style={{ fontSize: 14 }}>
- {entry.message ? formatBytes(entry.message.size) : '-'}
+ {entry.message ? formatOptionalBytes(entry.message.size) : '-'}
</span>
),
},
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index e1188bf63..1f430dc28 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -24,6 +24,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect,
it, vi } from 'vite
import type { MessageRecord } from '../../../api/message';
import { LangProvider } from '../../../i18n/LangContext';
import * as downloadUtils from '../../../utils/download';
+import { UNKNOWN_MESSAGE_SIZE } from '../../../utils/format';
const messageServiceMocks = vi.hoisted(() => ({
consumeMessageDirectly: vi.fn(),
@@ -923,4 +924,30 @@ describe('Message page query history', () => {
// snap back and silently reuse the previous window. It must not be
offered.
expect(container.querySelector('.ant-picker-clear')).toBeNull();
});
+
+ it('renders an unknown message size as unavailable instead of 0 B', async ()
=> {
+ const user = userEvent.setup();
+ messageServiceMocks.queryMessages.mockResolvedValue([
+ { ...createMessage('MID-UNKNOWN-SIZE'), size: UNKNOWN_MESSAGE_SIZE },
+ ]);
+ renderWithProviders(<MessagePage />);
+
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ const row = await screen.findByRole('row', { name: /MID-UNKNOWN-SIZE/ });
+ // -1 is the server's unknown sentinel: the size column must not claim a
fabricated
+ // "0 B", and the sentinel itself must not leak into the table either.
+ expect(within(row).queryByText('0 B')).not.toBeInTheDocument();
+ expect(within(row).queryByText('-1 B')).not.toBeInTheDocument();
+ await user.click(within(row).getByRole('button', { name: /详情/ }));
+
+ const dialog = await screen.findByRole('dialog', { name: '消息详情' });
+ const sizeItems = within(dialog)
+ .getAllByText('大小')
+ .map((label) => label.closest('.ant-descriptions-item'));
+ expect(sizeItems).toHaveLength(1);
+ const content =
sizeItems[0]?.querySelector('.ant-descriptions-item-content');
+ expect(content?.textContent?.trim()).toBe('-');
+ });
});
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 75064eaea..1fa14f6c7 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -79,7 +79,7 @@ import { useInstanceFilter } from
'../../hooks/useInstanceFilter';
import useAuthStore from '../../stores/authStore';
import { downloadBlob } from '../../utils/download';
import { describeThrownMessage } from '../../utils/apiError';
-import { formatBytes, formatOptionalTimeMs, formatTimeMs } from
'../../utils/format';
+import { formatOptionalBytes, formatOptionalTimeMs, formatTimeMs } from
'../../utils/format';
import {
readMessageTraceTopic,
writeMessageTraceTopic,
@@ -904,7 +904,7 @@ const MessagePageContent = ({
// 「12.3 MB」实测 48px + 左右 padding 16px;原来给 80 是把宽度让给了溢出的操作列。
width: 68,
align: 'right',
- render: (size: number) => formatBytes(size),
+ render: (size: number) => formatOptionalBytes(size),
},
{
title: t('common.actions'),
@@ -1027,7 +1027,7 @@ const MessagePageContent = ({
<span style={{ fontFamily: 'monospace'
}}>{selectedMsg.key}</span>
</Descriptions.Item>
<Descriptions.Item label={t('messagePage.size')}>
- {formatBytes(selectedMsg.size)}
+ {formatOptionalBytes(selectedMsg.size)}
</Descriptions.Item>
<Descriptions.Item label={t('messagePage.reconsumeTimes')}>
<span style={{ fontFamily: 'monospace'
}}>{selectedMsg.reconsumeTimes ?? '-'}</span>
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index 8e7b992b0..6cc438881 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import {
+ UNKNOWN_MESSAGE_SIZE,
formatBytes,
formatDate,
formatDateTime,
formatUtcDateTime,
formatDelay,
formatNumber,
+ formatOptionalBytes,
formatPercent,
formatRelativeTime,
formatTimeOfDay,
@@ -121,6 +123,17 @@ describe('formatBytes', () => {
});
});
+describe('formatOptionalBytes', () => {
+ it('renders the unknown-size sentinel as the placeholder instead of -1 B',
() => {
+ expect(formatOptionalBytes(UNKNOWN_MESSAGE_SIZE)).toBe('-');
+ });
+
+ it('keeps a measured zero as a real byte count', () => {
+ expect(formatOptionalBytes(0)).toBe('0 B');
+ expect(formatOptionalBytes(1536)).toBe('1.5 KB');
+ });
+});
+
describe('parseAlertTimestamp', () => {
it('parses an offset-less alert timestamp as UTC regardless of the browser
zone', () => {
// Expressed against Date.parse with an explicit Z so the expectation
cannot drift with TZ.
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 1f4f9cc95..8252092d1 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -301,3 +301,15 @@ export function ratioToPercent(value: number): number {
export function formatOptionalTimeMs(value: number | string | null |
undefined): string {
return value === 0 || value === '0' ? '-' : formatTimeMs(value);
}
+
+/** The server's sentinel for a message body size the vendor API does not
report: unknown, not zero. */
+export const UNKNOWN_MESSAGE_SIZE = -1;
+
+/**
+ * Message records report "no body size" as {@link UNKNOWN_MESSAGE_SIZE}.
{@link formatBytes}
+ * deliberately formats negatives as real byte counts, so the message-size
call sites map the
+ * sentinel to the placeholder themselves.
+ */
+export function formatOptionalBytes(bytes: number): string {
+ return bytes < 0 ? '-' : formatBytes(bytes);
+}