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 684915725 fix(instance): lifecycle cleanup and credential-scoped 
client release (#2233)
684915725 is described below

commit 6849157252f04ce9965bb81aee9c39cb39e26ed5
Author: shown <[email protected]>
AuthorDate: Wed Aug 19 11:20:38 2026 +0800

    fix(instance): lifecycle cleanup and credential-scoped client release 
(#2233)
    
    * fix(instance): release credential-scoped admin clients and remove metrics 
bindings on deletion
    
    When an instance's endpoint or admin credential reference changes, only 
stop the
    cached admin client that is actually unreferenced: release every identity 
on the
    endpoint only when no other instance uses it, otherwise evict the single
    credential-scoped client so other identities sharing the endpoint keep 
running.
    Deleting an instance now also strips its identifier from every metrics data
    source binding inside the same transaction, so dashboards no longer 
reference a
    removed instance.
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * fix(credentials): prevent deleted credential recreation on update
    
    Credential updates used save(), which re-inserts the row when it vanished
    between lookup and persistence, silently resurrecting a concurrently deleted
    credential. Switch the update path to a replace() that performs a pure 
update
    and reports whether a row changed, returning 404 instead of recreating the
    credential.
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    ---------
    
    Signed-off-by: yuluo-yx <[email protected]>
---
 .../studio/cluster/broker/MqAdminExtFactory.java   | 19 +++++++
 .../rocketmq/studio/instance/InstanceService.java  | 58 ++++++++++++++++------
 .../credential/CloudCredentialRepository.java      |  2 +
 .../credential/CloudCredentialService.java         | 12 +++--
 .../MybatisPlusCloudCredentialRepository.java      |  5 ++
 .../cluster/broker/MqAdminExtFactoryTest.java      | 16 ++++++
 .../studio/instance/InstanceServiceTest.java       | 25 ++++++++++
 .../credential/CloudCredentialServiceTest.java     | 22 +++++++-
 8 files changed, 137 insertions(+), 22 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactory.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactory.java
index 0377f8671..b491b8636 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactory.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactory.java
@@ -132,6 +132,25 @@ public class MqAdminExtFactory {
         log.info("Released RocketMQ admin clients for namesrv {}", 
normalizedNamesrvAddr);
     }
 
+    /** Stops one credential-scoped client without interrupting other 
identities on the endpoint. */
+    public void release(String namesrvAddr, String authenticationIdentity) {
+        if (namesrvAddr == null || namesrvAddr.isBlank()) {
+            return;
+        }
+        String normalizedNamesrvAddr = normalizeNamesrvAddr(namesrvAddr);
+        if (normalizedNamesrvAddr.isEmpty()) {
+            return;
+        }
+        AdminClientCacheKey key = new 
AdminClientCacheKey(normalizedNamesrvAddr,
+                normalizeAuthenticationIdentity(authenticationIdentity));
+        DefaultMQAdminExt admin = cache.remove(key);
+        if (admin != null) {
+            safeShutdown(admin);
+            log.info("Released RocketMQ admin client for namesrv {} and 
identity {}",
+                    normalizedNamesrvAddr, key.authenticationIdentity());
+        }
+    }
+
     private DefaultMQAdminExt createAndStart(String namesrvAddr, RPCHook 
rpcHook) {
         DefaultMQAdminExt admin = newAdmin(rpcHook);
         admin.setNamesrvAddr(namesrvAddr);
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 e3d6002d9..00c0db05a 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
@@ -29,9 +29,12 @@ import 
org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
 import org.apache.rocketmq.studio.provider.InstanceProvider;
 import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.apache.rocketmq.studio.settings.DataSourceVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
 import java.util.ArrayList;
@@ -50,6 +53,7 @@ public class InstanceService {
     private final InstanceProviderRegistry providerRegistry;
     private final MqAdminExtFactory adminFactory;
     private final OperationAuditService operationAuditService;
+    private final SettingsRepository settingsRepository;
 
     public List<InstanceVO> listInstances(InstanceType type, String search) {
         log.debug("Listing instances, type={}, search={}", type, search);
@@ -292,6 +296,7 @@ public class InstanceService {
         return saved;
     }
 
+    @Transactional
     public void deleteInstance(Long id) {
         log.info("Deleting instance: {}", id);
 
@@ -314,11 +319,28 @@ public class InstanceService {
         if (!instanceRepository.deleteById(id)) {
             throw new BusinessException(404, "InstanceVO not found: " + id);
         }
+        removeDataSourceBindings(existing.getName());
         releaseApacheEndpointIfUnused(existing, null);
         recordAudit("DELETE_INSTANCE", "INSTANCE", String.valueOf(id), null,
                 instanceAuditDetail(existing));
     }
 
+    private void removeDataSourceBindings(String instanceId) {
+        for (DataSourceVO dataSource : 
settingsRepository.findAllDataSources()) {
+            List<String> instanceIds = dataSource.getInstanceIds();
+            if (instanceIds == null || !instanceIds.contains(instanceId)) {
+                continue;
+            }
+            dataSource.setInstanceIds(instanceIds.stream()
+                    .filter(candidate -> !instanceId.equals(candidate))
+                    .toList());
+            if (!settingsRepository.replaceDataSource(dataSource)) {
+                log.warn("Metrics data source {} disappeared while removing 
instance binding {}",
+                        dataSource.getKey(), instanceId);
+            }
+        }
+    }
+
     private void requireInstance(InstanceVO instance) {
         if (instance == null) {
             throw new BusinessException(400, "Instance request is required");
@@ -355,7 +377,7 @@ public class InstanceService {
         if (vendor != InstanceVendor.APACHE) {
             return;
         }
-        releaseEndpointIfUnused(existing.getEndpoint(), currentEndpoint, 
existing.getId());
+        releaseOldClientIfUnused(existing, currentEndpoint, null, 
existing.getId());
     }
 
     private void releaseApacheClientIfChanged(InstanceVO existing, InstanceVO 
saved) {
@@ -363,25 +385,31 @@ public class InstanceService {
         if (vendor != InstanceVendor.APACHE) {
             return;
         }
-        if 
(!Objects.equals(normalizeCredentialRef(existing.getAdminCredentialRef()),
-                normalizeCredentialRef(saved.getAdminCredentialRef()))
-                && Objects.equals(normalizeEndpoint(existing.getEndpoint()), 
normalizeEndpoint(saved.getEndpoint()))) {
-            adminFactory.release(existing.getEndpoint());
-            return;
-        }
-        releaseEndpointIfUnused(existing.getEndpoint(), saved.getEndpoint(), 
existing.getId());
+        releaseOldClientIfUnused(existing, saved.getEndpoint(), 
saved.getAdminCredentialRef(), existing.getId());
     }
 
-    private void releaseEndpointIfUnused(String previousEndpoint, String 
currentEndpoint, Long excludedInstanceId) {
-        String oldEndpoint = normalizeEndpoint(previousEndpoint);
-        if (oldEndpoint == null || 
oldEndpoint.equals(normalizeEndpoint(currentEndpoint))) {
+    private void releaseOldClientIfUnused(InstanceVO existing, String 
currentEndpoint,
+                                          String currentCredentialRef, Long 
excludedInstanceId) {
+        String oldEndpoint = normalizeEndpoint(existing.getEndpoint());
+        String oldCredentialRef = 
normalizeCredentialRef(existing.getAdminCredentialRef());
+        if (oldEndpoint == null || 
oldEndpoint.equals(normalizeEndpoint(currentEndpoint))
+                && Objects.equals(oldCredentialRef, 
normalizeCredentialRef(currentCredentialRef))) {
             return;
         }
-        boolean stillReferenced = instanceRepository.findAll().stream()
-                .anyMatch(instance -> 
!excludedInstanceId.equals(instance.getId())
-                        && 
oldEndpoint.equals(normalizeEndpoint(instance.getEndpoint())));
-        if (!stillReferenced) {
+        List<InstanceVO> remaining = instanceRepository.findAll().stream()
+                .filter(instance -> 
!excludedInstanceId.equals(instance.getId()))
+                .toList();
+        boolean endpointReferenced = remaining.stream()
+                .anyMatch(instance -> 
oldEndpoint.equals(normalizeEndpoint(instance.getEndpoint())));
+        if (!endpointReferenced) {
             adminFactory.release(oldEndpoint);
+            return;
+        }
+        boolean identityReferenced = remaining.stream()
+                .anyMatch(instance -> 
oldEndpoint.equals(normalizeEndpoint(instance.getEndpoint()))
+                        && Objects.equals(oldCredentialRef, 
normalizeCredentialRef(instance.getAdminCredentialRef())));
+        if (!identityReferenced) {
+            adminFactory.release(oldEndpoint, oldCredentialRef);
         }
     }
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
index 91aff5a28..9e76d9f7c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
@@ -31,5 +31,7 @@ public interface CloudCredentialRepository {
 
     CloudCredentialVO save(CloudCredentialVO credential);
 
+    boolean replace(CloudCredentialVO credential);
+
     boolean deleteById(Long id);
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
index 5dd010fa1..b0ad47bde 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
@@ -97,11 +97,13 @@ public class CloudCredentialService {
             existing.setRemark(request.getRemark());
         }
         existing.setGmtModified(LocalDateTime.now());
-        CloudCredentialVO saved = credentialRepository.save(existing);
-        invalidateCloudClients(saved);
-        recordAudit("UPDATE_CLOUD_CREDENTIAL", "CLOUD_CREDENTIAL", 
String.valueOf(saved.getId()), null,
-                credentialAuditDetail(saved));
-        return maskAccessKey(saved);
+        if (!credentialRepository.replace(existing)) {
+            throw new BusinessException(404, "Cloud credential not found: " + 
request.getId());
+        }
+        invalidateCloudClients(existing);
+        recordAudit("UPDATE_CLOUD_CREDENTIAL", "CLOUD_CREDENTIAL", 
String.valueOf(existing.getId()), null,
+                credentialAuditDetail(existing));
+        return maskAccessKey(existing);
     }
 
     public void delete(Long id) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
index 262f5b36d..36e555e49 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
@@ -82,6 +82,11 @@ public class MybatisPlusCloudCredentialRepository implements 
CloudCredentialRepo
         return credential;
     }
 
+    @Override
+    public boolean replace(CloudCredentialVO credential) {
+        return credential.getId() != null && 
credentialMapper.updateById(toEntity(credential)) > 0;
+    }
+
     @Override
     public boolean deleteById(Long id) {
         return id != null && credentialMapper.deleteById(id) > 0;
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactoryTest.java
index d04e0f7d7..b7ee85ad0 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactoryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/MqAdminExtFactoryTest.java
@@ -84,6 +84,22 @@ class MqAdminExtFactoryTest {
         verify(admin).shutdown();
     }
 
+    @Test
+    void releaseShouldEvictOnlyMatchingCredentialIdentityTest() throws 
Exception {
+        DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+        RecordingFactory factory = new RecordingFactory(admin);
+
+        factory.execute("10.0.0.1:9876", null, "credential-a", ignored -> 
null);
+        factory.execute("10.0.0.1:9876", null, "credential-b", ignored -> 
null);
+        factory.release("10.0.0.1:9876", "credential-a");
+        factory.execute("10.0.0.1:9876", null, "credential-b", ignored -> 
null);
+        factory.execute("10.0.0.1:9876", null, "credential-a", ignored -> 
null);
+
+        assertThat(factory.created.get()).isEqualTo(3);
+        verify(admin, times(3)).start();
+        verify(admin).shutdown();
+    }
+
     @Test
     void releaseShouldEvictClientUsingEquivalentNameServerAddressList() throws 
Exception {
         DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
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 2e6f93ce4..5ce91ec65 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
@@ -28,6 +28,8 @@ import 
org.apache.rocketmq.studio.provider.CloudCatalogProvider;
 import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
 import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
 import org.apache.rocketmq.studio.provider.InstanceProvider;
+import org.apache.rocketmq.studio.settings.DataSourceVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InjectMocks;
@@ -71,6 +73,9 @@ class InstanceServiceTest {
     @Mock
     private OperationAuditService operationAuditService;
 
+    @Mock
+    private SettingsRepository settingsRepository;
+
     @InjectMocks
     private InstanceService instanceService;
 
@@ -744,6 +749,26 @@ class InstanceServiceTest {
         verify(adminFactory).release("namesrv:9876");
     }
 
+    @Test
+    void deleteInstanceShouldRemoveMetricsDataSourceBindingTest() {
+        InstanceVO existing = InstanceVO.builder().name("to-delete").build();
+        existing.setId(1L);
+        DataSourceVO dataSource = DataSourceVO.builder().key("prometheus")
+                .instanceIds(List.of("to-delete", "inst-2")).build();
+        
when(instanceRepository.findById(1L)).thenReturn(Optional.of(existing));
+        
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+        when(instanceProvider.countTopics("1")).thenReturn(0);
+        when(instanceProvider.countGroups("1")).thenReturn(0);
+        when(instanceRepository.deleteById(1L)).thenReturn(true);
+        
when(settingsRepository.findAllDataSources()).thenReturn(List.of(dataSource));
+        
when(settingsRepository.replaceDataSource(dataSource)).thenReturn(true);
+
+        instanceService.deleteInstance(1L);
+
+        assertThat(dataSource.getInstanceIds()).containsExactly("inst-2");
+        verify(settingsRepository).replaceDataSource(dataSource);
+    }
+
     @Test
     void deleteInstanceShouldRejectConcurrentRemoval() {
         InstanceVO existing = InstanceVO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
index ddb5d4005..89d248f98 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
@@ -167,8 +167,7 @@ class CloudCredentialServiceTest {
         stored.setAccessKey("LTAI5tUpdateKey000000001");
         stored.setSecretKey("old-secret");
         
when(credentialRepository.findById(1L)).thenReturn(Optional.of(stored));
-        when(credentialRepository.save(any(CloudCredentialVO.class)))
-                .thenAnswer(invocation -> invocation.getArgument(0));
+        
when(credentialRepository.replace(any(CloudCredentialVO.class))).thenReturn(true);
 
         UpdateCloudCredentialDTO request = new UpdateCloudCredentialDTO();
         request.setId(1L);
@@ -181,6 +180,25 @@ class CloudCredentialServiceTest {
                 eq("1"), eq(null), eq("name=null, vendor=ALIYUN"), 
eq("SUCCESS"), eq(null));
     }
 
+    @Test
+    void updateShouldNotRecreateConcurrentlyDeletedCredentialTest() {
+        CloudCredentialVO stored = new CloudCredentialVO();
+        stored.setId(1L);
+        stored.setVendor(InstanceVendor.TENCENT);
+        stored.setAccessKey("AKIDexample");
+        
when(credentialRepository.findById(1L)).thenReturn(Optional.of(stored));
+        when(credentialRepository.replace(stored)).thenReturn(false);
+        UpdateCloudCredentialDTO request = new UpdateCloudCredentialDTO();
+        request.setId(1L);
+        request.setName("renamed");
+
+        assertThatThrownBy(() -> service.update(request))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Cloud credential not found: 1");
+
+        verify(tencentClientFactory, never()).invalidateCredential(any());
+    }
+
     @Test
     void deleteShouldInvalidateAliyunClientsAfterRemovingCredentialTest() {
         CloudCredentialVO stored = new CloudCredentialVO();

Reply via email to