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();
 }

Reply via email to