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 8457a929d fix(dlq): isolate detail resend state across instance
switches (#4487)
8457a929d is described below
commit 8457a929dbf2829ecfc126bc07e1f15e27d76d8a
Author: zmuxuny <[email protected]>
AuthorDate: Mon Sep 21 12:06:59 2026 +0800
fix(dlq): isolate detail resend state across instance switches (#4487)
The dead-letter detail drawer shared its in-flight flag and request
generation
with the group retry flow, so a resend started under one instance could
resolve
after an instance switch and write into the new scope, and the two paths
could
not tell each other apart.
Give the detail resend its own request-id and in-flight refs, make the two
paths
mutually exclusive, and reset the drawer state (open flag, messages, page,
selection, loading, error) when the instance changes or the drawer closes.
---
web/src/pages/instance/__tests__/DLQPage.test.tsx | 96 +++++++++++++++++++++++
web/src/pages/instance/dlq.tsx | 58 +++++++++++---
2 files changed, 142 insertions(+), 12 deletions(-)
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 29cf71068..5025b85ba 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -259,6 +259,102 @@ describe('DLQ page', () => {
);
});
+ it('does not let an old-instance detail resend overwrite the new instance
drawer', async () => {
+ let resolveResend!: (result: DLQResendResult) => void;
+ let resolveSecondDetail!: (page: DLQMessagePage) => void;
+ let firstInstanceDetailCalls = 0;
+ vi.mocked(messageService.listDLQGroups)
+ .mockResolvedValueOnce(pageOf([dlqGroup]))
+ .mockResolvedValueOnce(pageOf([secondDlqGroup]));
+ vi.mocked(messageService.listDLQMessages).mockImplementation((params) => {
+ if (params.instanceId === 'instance-2') {
+ return new Promise<DLQMessagePage>((resolve) => {
+ resolveSecondDetail = resolve;
+ });
+ }
+ firstInstanceDetailCalls += 1;
+ return Promise.resolve({
+ items: [
+ {
+ msgId: firstInstanceDetailCalls === 1 ? 'instance-a-initial' :
'stale-a-after-resend',
+ topic: 'orders',
+ queueId: 0,
+ offset: firstInstanceDetailCalls,
+ storeTime: 1_700_000_000_000,
+ keys: 'instance-a-key',
+ body: 'a-body',
+ bodyBase64: null,
+ properties: {},
+ propertiesTruncated: false,
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ });
+ vi.mocked(messageService.resendDLQSelected).mockImplementationOnce(
+ () =>
+ new Promise<DLQResendResult>((resolve) => {
+ resolveResend = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ const firstGroupRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!firstGroupRow) throw new Error('first DLQ group row not found');
+ await user.click(within(firstGroupRow).getByRole('button', { name: /消息明细/
}));
+ const firstMessageRow = (await
screen.findByText('instance-a-initial')).closest('tr');
+ if (!firstMessageRow) throw new Error('first DLQ message row not found');
+ await user.click(within(firstMessageRow).getByRole('checkbox'));
+ await user.click(screen.getByRole('button', { name: /批量重发选中/ }));
+ expect(messageService.resendDLQSelected).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-1', groupName:
'cg-order' }),
+ );
+
+ await user.click(screen.getAllByRole('combobox')[0]);
+ await user.click(
+ await screen.findByText('instance-2', { selector:
'.ant-select-item-option-content' }),
+ );
+ const secondGroupRow = (await
screen.findByText('-cg-"payment"')).closest('tr');
+ if (!secondGroupRow) throw new Error('second DLQ group row not found');
+ await user.click(within(secondGroupRow).getByRole('button', { name: /消息明细/
}));
+ expect(await screen.findByText('DLQ 消息明细 ·
-cg-"payment"')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(messageService.listDLQMessages).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-2', groupName:
'-cg-"payment"' }),
+ ),
+ );
+
+ await act(async () => resolveResend({ matched: 1, resent: 1, failed: 0,
outcome: 'SUCCESS' }));
+ expect(firstInstanceDetailCalls).toBe(1);
+ await act(async () =>
+ resolveSecondDetail({
+ items: [
+ {
+ msgId: 'instance-b-message',
+ topic: 'payments',
+ queueId: 1,
+ offset: 7,
+ storeTime: 1_700_000_001_000,
+ keys: 'instance-b-key',
+ body: 'b-body',
+ bodyBase64: null,
+ properties: {},
+ propertiesTruncated: false,
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ }),
+ );
+
+ expect(await screen.findByText('instance-b-message')).toBeInTheDocument();
+ expect(screen.queryByText('stale-a-after-resend')).not.toBeInTheDocument();
+ });
+
it('shows user properties in the DLQ message drawer', async () => {
vi.mocked(messageService.listDLQMessages).mockResolvedValue({
items: [
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index e32e06747..26f46df81 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -142,14 +142,17 @@ const DLQPage = () => {
const [detailResending, setDetailResending] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const detailRequestIdRef = useRef(0);
+ const detailResendRequestIdRef = useRef(0);
const retryRequestIdRef = useRef(0);
const groupRequestIdRef = useRef(0);
- const resendInFlightRef = useRef(false);
+ const retryInFlightRef = useRef(false);
+ const detailResendInFlightRef = useRef(false);
useEffect(
() => () => {
retryRequestIdRef.current += 1;
detailRequestIdRef.current += 1;
+ detailResendRequestIdRef.current += 1;
},
[],
);
@@ -168,7 +171,15 @@ const DLQPage = () => {
setTotal(0);
setPage(1);
setSelectedGroupNames([]);
+ setDetailOpen(false);
setDetailGroup(null);
+ setDetailMessages([]);
+ setDetailTotal(0);
+ setDetailPage(1);
+ setDetailSelectedMsgIds([]);
+ setDetailLoading(false);
+ setDetailResending(false);
+ setDetailError(null);
setRetryModalOpen(false);
setRetryGroup(null);
setRetryTargetTopic('');
@@ -229,7 +240,12 @@ const DLQPage = () => {
/* ─── Handlers ─── */
const handleInstanceChange = (instanceId: string) => {
retryRequestIdRef.current += 1;
+ detailRequestIdRef.current += 1;
+ detailResendRequestIdRef.current += 1;
+ retryInFlightRef.current = false;
+ detailResendInFlightRef.current = false;
setRetrySubmitting(false);
+ setDetailResending(false);
selectInstance(instanceId);
};
@@ -247,9 +263,9 @@ const DLQPage = () => {
return;
}
if (!retryGroup || !selectedInstanceId) return;
- if (resendInFlightRef.current) return;
+ if (retryInFlightRef.current || detailResendInFlightRef.current) return;
- resendInFlightRef.current = true;
+ retryInFlightRef.current = true;
const requestId = retryRequestIdRef.current + 1;
retryRequestIdRef.current = requestId;
const groupName = retryGroup.groupName;
@@ -283,8 +299,8 @@ const DLQPage = () => {
setRetryError(getErrorMessage(error, DEFAULT_RETRY_ERROR));
}
} finally {
- resendInFlightRef.current = false;
if (retryRequestIdRef.current === requestId) {
+ retryInFlightRef.current = false;
setRetrySubmitting(false);
}
}
@@ -318,6 +334,10 @@ const DLQPage = () => {
/* ─── DLQ Message Details Drawer ─── */
const openDetailDrawer = (group: DLQGroup) => {
+ detailRequestIdRef.current += 1;
+ detailResendRequestIdRef.current += 1;
+ detailResendInFlightRef.current = false;
+ setDetailResending(false);
setDetailGroup(group);
setDetailOpen(true);
setDetailPage(1);
@@ -358,16 +378,23 @@ const DLQPage = () => {
const resendSelectedMessages = async (msgIds: string[]) => {
if (!selectedInstanceId || !detailGroup || msgIds.length === 0) return;
- if (resendInFlightRef.current) return;
- resendInFlightRef.current = true;
+ if (retryInFlightRef.current || detailResendInFlightRef.current) return;
+ detailResendInFlightRef.current = true;
+ const requestId = detailResendRequestIdRef.current + 1;
+ detailResendRequestIdRef.current = requestId;
+ const instanceId = selectedInstanceId;
+ const group = detailGroup;
+ const pageToReload = detailPage;
+ const pageSizeToReload = detailPageSize;
setDetailResending(true);
setDetailError(null);
try {
const result = await resendDLQSelected({
- instanceId: selectedInstanceId,
- groupName: detailGroup.groupName,
+ instanceId,
+ groupName: group.groupName,
msgIds,
});
+ if (detailResendRequestIdRef.current !== requestId) return;
if (result.outcome === 'FAILED' && result.failed > 0) {
message.error(`重发失败:成功 ${result.resent},失败 ${result.failed}`);
} else if (result.resent > 0 && result.failed > 0) {
@@ -376,12 +403,16 @@ const DLQPage = () => {
message.success(`重发完成:成功 ${result.resent} 条`);
}
setDetailSelectedMsgIds([]);
- await loadDetailMessages(detailGroup, detailPage, detailPageSize);
+ await loadDetailMessages(group, pageToReload, pageSizeToReload);
} catch (error) {
- setDetailError(getErrorMessage(error, '重发死信消息失败,请稍后重试'));
+ if (detailResendRequestIdRef.current === requestId) {
+ setDetailError(getErrorMessage(error, '重发死信消息失败,请稍后重试'));
+ }
} finally {
- resendInFlightRef.current = false;
- setDetailResending(false);
+ if (detailResendRequestIdRef.current === requestId) {
+ detailResendInFlightRef.current = false;
+ setDetailResending(false);
+ }
}
};
@@ -795,6 +826,9 @@ const DLQPage = () => {
open={detailOpen}
onClose={() => {
detailRequestIdRef.current += 1;
+ detailResendRequestIdRef.current += 1;
+ detailResendInFlightRef.current = false;
+ setDetailResending(false);
setDetailOpen(false);
setDetailGroup(null);
setDetailMessages([]);