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 665e66a61 fix(web): invalidate inflight instance list requests after a
mutation (#4874)
665e66a61 is described below
commit 665e66a61773c981e53760c3c89f5b64e7a96a62
Author: Apulupie <[email protected]>
AuthorDate: Thu Sep 24 18:19:50 2026 +0800
fix(web): invalidate inflight instance list requests after a mutation
(#4874)
fix(web): drop inflight instance list requests after a mutation
A list request that started before createInstance/updateInstance/delete
resolved could still be joined by the refresh issued after the mutation,
serving the pre-mutation snapshot to the caller. Clear the dedupe cache
after every successful mutation so a post-write read always issues a
fresh request.
---
web/src/services/instanceService.test.ts | 109 ++++++++++++++++++++++++++++++-
web/src/services/instanceService.ts | 34 ++++++++--
2 files changed, 136 insertions(+), 7 deletions(-)
diff --git a/web/src/services/instanceService.test.ts
b/web/src/services/instanceService.test.ts
index c5dd08eb7..d19c3f29d 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -19,7 +19,13 @@ import { beforeEach, describe, expect, it, vi } from
'vitest';
const dataModeMock = vi.hoisted(() => ({ isMockMode: vi.fn(() => true) }));
vi.mock('./dataMode', () => dataModeMock);
-const instanceApiMock = vi.hoisted(() => ({ listInstances: vi.fn() }));
+const instanceApiMock = vi.hoisted(() => ({
+ listInstances: vi.fn(),
+ createInstance: vi.fn(),
+ updateInstance: vi.fn(),
+ deleteInstance: vi.fn(),
+ deleteInstancesBatch: vi.fn(),
+}));
vi.mock('../api/instance', () => instanceApiMock);
vi.mock('../config', () => ({
API_BASE_URL: '/api',
@@ -28,6 +34,7 @@ vi.mock('../config', () => ({
import {
createInstance,
deleteInstance,
+ deleteInstancesBatch,
getInstanceCapabilities,
listInstances,
updateInstance,
@@ -187,3 +194,103 @@ describe('instanceService list request dedupe', () => {
await expect(realRequest).resolves.toEqual([]);
});
});
+
+describe('instanceService dedupe invalidation after mutations', () => {
+ beforeEach(() => {
+ dataModeMock.isMockMode.mockReturnValue(false);
+ instanceApiMock.listInstances.mockReset();
+ });
+
+ function instanceFixture(name: string, remark: string | null = null):
Instance {
+ return {
+ id: 1,
+ name,
+ remark,
+ type: 'PROXY_CLUSTER',
+ endpoint: '10.0.0.1:8080',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-01-01T00:00:00Z',
+ gmtModified: '2026-01-01T00:00:00Z',
+ };
+ }
+
+ function holdFirstListRequest(): { resolve: (value: Instance[]) => void } {
+ const holder: { resolve?: (value: Instance[]) => void } = {};
+ instanceApiMock.listInstances.mockImplementationOnce(
+ () =>
+ new Promise<Instance[]>((resolve) => {
+ holder.resolve = resolve;
+ }),
+ );
+ return holder as { resolve: (value: Instance[]) => void };
+ }
+
+ it('does not serve the pre-create snapshot to a list request issued after
createInstance', async () => {
+ const before = [instanceFixture('kept')];
+ const after = [instanceFixture('kept'), instanceFixture('created')];
+ const first = holdFirstListRequest();
+ instanceApiMock.listInstances.mockResolvedValueOnce(after);
+
+ const staleRead = listInstances({});
+ await createInstance({
+ name: 'created',
+ type: 'PROXY_CLUSTER',
+ endpoint: '10.0.0.2:8080',
+ });
+ const refresh = listInstances({});
+ first.resolve(before);
+
+ await expect(staleRead).resolves.toEqual(before);
+ await expect(refresh).resolves.toEqual(after);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not serve the pre-update snapshot to a list request issued after
updateInstance', async () => {
+ const before = [instanceFixture('kept', 'old')];
+ const after = [instanceFixture('kept', 'updated')];
+ const first = holdFirstListRequest();
+ instanceApiMock.listInstances.mockResolvedValueOnce(after);
+
+ const staleRead = listInstances({});
+ await updateInstance({ instanceId: 'kept', remark: 'updated' });
+ const refresh = listInstances({});
+ first.resolve(before);
+
+ await expect(staleRead).resolves.toEqual(before);
+ await expect(refresh).resolves.toEqual(after);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not serve the pre-delete snapshot to a list request issued after
deleteInstance', async () => {
+ const before = [instanceFixture('removed'), instanceFixture('kept')];
+ const after = [instanceFixture('kept')];
+ const first = holdFirstListRequest();
+ instanceApiMock.listInstances.mockResolvedValueOnce(after);
+
+ const staleRead = listInstances({});
+ await deleteInstance('removed');
+ const refresh = listInstances({});
+ first.resolve(before);
+
+ await expect(staleRead).resolves.toEqual(before);
+ await expect(refresh).resolves.toEqual(after);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not serve the pre-delete snapshot to a list request issued after
deleteInstancesBatch', async () => {
+ const before = [instanceFixture('removed'), instanceFixture('kept')];
+ const after = [instanceFixture('kept')];
+ const first = holdFirstListRequest();
+ instanceApiMock.listInstances.mockResolvedValueOnce(after);
+
+ const staleRead = listInstances({});
+ await deleteInstancesBatch(['removed']);
+ const refresh = listInstances({});
+ first.resolve(before);
+
+ await expect(staleRead).resolves.toEqual(before);
+ await expect(refresh).resolves.toEqual(after);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index af8f54a28..7badf7c25 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -40,6 +40,13 @@ const CLOUD_CAPABILITIES:
InstanceCapabilities['capabilities'] = [
const inflightListRequests = new Map<string, Promise<Instance[]>>();
+// A mutation that just completed must not be shadowed by a list request that
+// started earlier: callers issuing a read after a write would otherwise join
+// the stale inflight snapshot, so drop them after every successful mutation.
+function invalidateInflightListRequests(): void {
+ inflightListRequests.clear();
+}
+
export function listInstances(query: InstanceQuery = {}): Promise<Instance[]> {
const mockMode = isMockMode();
const key = JSON.stringify([mockMode, query.type ?? null,
query.search?.trim() || null]);
@@ -101,9 +108,12 @@ export async function createInstance(data:
CreateInstanceRequest): Promise<Insta
gmtModified: new Date().toISOString().replace('T', ' ').slice(0, 19),
};
mockInstances.push(instance);
+ invalidateInflightListRequests();
return copyInstance(instance);
}
- return instanceApi.createInstance(data);
+ const created = await instanceApi.createInstance(data);
+ invalidateInflightListRequests();
+ return created;
}
export async function importCloudInstances(data: {
@@ -111,9 +121,13 @@ export async function importCloudInstances(data: {
credentialId: number;
}): Promise<instanceApi.CloudImportResult> {
if (isMockMode()) {
- return { discovered: 0, imported: 0, skipped: 0, failed: [] };
+ const result = { discovered: 0, imported: 0, skipped: 0, failed: [] };
+ invalidateInflightListRequests();
+ return result;
}
- return instanceApi.importCloudInstances(data);
+ const result = await instanceApi.importCloudInstances(data);
+ invalidateInflightListRequests();
+ return result;
}
export async function deleteInstancesBatch(ids: string[]):
Promise<instanceApi.BatchDeleteResult> {
@@ -126,9 +140,12 @@ export async function deleteInstancesBatch(ids: string[]):
Promise<instanceApi.B
const idx = mockInstances.findIndex((instance) => instance.name === id);
if (idx >= 0) mockInstances.splice(idx, 1);
}
+ invalidateInflightListRequests();
return { deleted: ids.length - failed.length, failed };
}
- return instanceApi.deleteInstancesBatch(ids);
+ const result = await instanceApi.deleteInstancesBatch(ids);
+ invalidateInflightListRequests();
+ return result;
}
export async function updateInstance(data: UpdateInstanceRequest):
Promise<Instance> {
@@ -139,11 +156,14 @@ export async function updateInstance(data:
UpdateInstanceRequest): Promise<Insta
Object.assign(mockInstances[idx], changes, {
gmtModified: new Date().toISOString().replace('T', ' ').slice(0, 19),
});
+ invalidateInflightListRequests();
return copyInstance(mockInstances[idx]);
}
throw new Error('Instance not found');
}
- return instanceApi.updateInstance(data);
+ const updated = await instanceApi.updateInstance(data);
+ invalidateInflightListRequests();
+ return updated;
}
export async function deleteInstance(instanceId: string): Promise<void> {
@@ -151,7 +171,9 @@ export async function deleteInstance(instanceId: string):
Promise<void> {
const idx = mockInstances.findIndex((i) => i.name === instanceId);
if (idx < 0) throw new Error(`Instance not found: ${instanceId}`);
mockInstances.splice(idx, 1);
+ invalidateInflightListRequests();
return;
}
- return instanceApi.deleteInstance(instanceId);
+ await instanceApi.deleteInstance(instanceId);
+ invalidateInflightListRequests();
}