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 94d2f86f7 [ISSUE #2914][ISSUE #2954][ISSUE #2953] fix(server):
consolidate persistence and audit correctness (#2919)
94d2f86f7 is described below
commit 94d2f86f7cabe0fa95ad0b1c04d3510a773c3915
Author: shown <[email protected]>
AuthorDate: Fri Sep 4 14:19:59 2026 +0800
[ISSUE #2914][ISSUE #2954][ISSUE #2953] fix(server): consolidate
persistence and audit correctness (#2919)
* [ISSUE #2914] Prevent stale resource recreation
* [ISSUE #2954] Preserve nested audit detail values
* [ISSUE #2953] Isolate audit persistence failures
---
.../studio/audit/OperationAuditService.java | 11 ++++-
.../cluster/k8s/MybatisPlusK8sCertRepository.java | 2 +-
.../MybatisPlusCloudCredentialRepository.java | 2 +-
.../studio/audit/OperationAuditServiceTest.java | 13 +++++
.../k8s/MybatisPlusK8sCertRepositoryTest.java | 16 ++++++-
.../MybatisPlusCloudCredentialRepositoryTest.java | 15 +++++-
.../pages/ops/__tests__/auditPresentation.test.ts | 15 ++++++
web/src/pages/ops/auditPresentation.ts | 55 +++++++++++++++++++++-
8 files changed, 122 insertions(+), 7 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
b/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
index c52baff5c..9959cad75 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
@@ -48,7 +48,14 @@ public class OperationAuditService {
LocalDateTime now = LocalDateTime.now();
audit.setGmtCreate(now);
audit.setGmtModified(now);
- auditMapper.insert(audit);
- log.debug("Audit recorded: {} {} {}", operation, resourceType,
resourceName);
+ try {
+ auditMapper.insert(audit);
+ log.debug("Audit recorded: {} {} {}", operation, resourceType,
resourceName);
+ } catch (RuntimeException auditFailure) {
+ // Audit is observational. A failed sink must not turn an
operation that already
+ // completed at a broker or provider into an API failure that
callers may retry.
+ log.warn("Failed to record audit operation={} resourceType={}
resource={}: {}",
+ operation, resourceType, resourceName,
auditFailure.getMessage());
+ }
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
index 7ab9b5769..f1839ba9c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepository.java
@@ -66,7 +66,7 @@ public class MybatisPlusK8sCertRepository implements
K8sCertRepository {
@Transactional
public K8sCertVO save(K8sCertVO cert) {
RmqK8sCertificate entity = toEntity(cert);
- if (entity.getId() != null && certMapper.selectById(entity.getId()) !=
null) {
+ if (entity.getId() != null) {
if (certMapper.updateById(entity) == 0) {
throw new BusinessException(409,
"Certificate update was not applied: " +
entity.getId());
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 f9676ffc8..b010fb3e0 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
@@ -85,7 +85,7 @@ public class MybatisPlusCloudCredentialRepository implements
CloudCredentialRepo
@Override
public CloudCredentialVO save(CloudCredentialVO credential) {
RmqCloudCredential entity = toEntity(credential);
- if (entity.getId() != null &&
credentialMapper.selectById(entity.getId()) != null) {
+ if (entity.getId() != null) {
if (credentialMapper.updateById(entity) == 0) {
throw new BusinessException(409,
"Cloud credential update was not applied: " +
entity.getId());
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/audit/OperationAuditServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/audit/OperationAuditServiceTest.java
index 645365a8a..7cab90d10 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/audit/OperationAuditServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/audit/OperationAuditServiceTest.java
@@ -28,6 +28,8 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@@ -66,4 +68,15 @@ class OperationAuditServiceTest {
assertThat(captor.getValue().getOperator())
.isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
}
+
+ @Test
+ void recordShouldNotPropagateAuditPersistenceFailures() {
+ OperationAuditService service = new OperationAuditService(auditMapper);
+ doThrow(new IllegalStateException("audit database unavailable"))
+
.when(auditMapper).insert(org.mockito.ArgumentMatchers.any(RmqOperationAudit.class));
+
+ assertThatCode(() -> service.record("DIRECT_CONSUME_MESSAGE",
"MESSAGE", "msg-1",
+ "cluster-a", "result=SUCCESS", "SUCCESS", null))
+ .doesNotThrowAnyException();
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
index 72d6f406d..00d3b3d29 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/MybatisPlusK8sCertRepositoryTest.java
@@ -26,6 +26,8 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MybatisPlusK8sCertRepositoryTest {
@@ -50,7 +52,6 @@ class MybatisPlusK8sCertRepositoryTest {
@Test
void saveShouldReportALostConcurrentUpdate() {
RmqK8sCertificateMapper mapper = mock(RmqK8sCertificateMapper.class);
- when(mapper.selectById(1L)).thenReturn(certificate());
when(mapper.updateById(any(RmqK8sCertificate.class))).thenReturn(0);
K8sCertVO cert = K8sCertVO.builder().k8sId("broker").build();
cert.setId(1L);
@@ -62,6 +63,19 @@ class MybatisPlusK8sCertRepositoryTest {
((BusinessException) error).getCode()).isEqualTo(409));
}
+ @Test
+ void saveShouldNotReinsertACertificateDeletedConcurrently() {
+ RmqK8sCertificateMapper mapper = mock(RmqK8sCertificateMapper.class);
+ when(mapper.updateById(any(RmqK8sCertificate.class))).thenReturn(0);
+ K8sCertVO cert = K8sCertVO.builder().k8sId("broker").build();
+ cert.setId(1L);
+
+ assertThatThrownBy(() -> repository(mapper).save(cert))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Certificate update was not applied: 1");
+ verify(mapper, never()).insert(any(RmqK8sCertificate.class));
+ }
+
@Test
void deleteByIdShouldReportWhetherARowWasRemoved() {
RmqK8sCertificateMapper mapper = mock(RmqK8sCertificateMapper.class);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepositoryTest.java
index 7ee62d058..b9b9ffa00 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepositoryTest.java
@@ -38,6 +38,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -54,7 +55,6 @@ class MybatisPlusCloudCredentialRepositoryTest {
CloudCredentialVO credential = new CloudCredentialVO();
credential.setId(1L);
credential.setVendor(InstanceVendor.ALIYUN);
- when(credentialMapper.selectById(1L)).thenReturn(entity(1L, "cred-1",
"ALIYUN"));
when(credentialMapper.updateById(any(RmqCloudCredential.class))).thenReturn(0);
assertThatThrownBy(() -> repository.save(credential))
@@ -63,6 +63,19 @@ class MybatisPlusCloudCredentialRepositoryTest {
.satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(409));
}
+ @Test
+ void saveShouldNotReinsertACredentialDeletedConcurrently() {
+ CloudCredentialVO credential = new CloudCredentialVO();
+ credential.setId(1L);
+ credential.setVendor(InstanceVendor.ALIYUN);
+
when(credentialMapper.updateById(any(RmqCloudCredential.class))).thenReturn(0);
+
+ assertThatThrownBy(() -> repository.save(credential))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Cloud credential update was not applied: 1");
+ verify(credentialMapper,
never()).insert(any(RmqCloudCredential.class));
+ }
+
@Test
void findByIdShouldMapValidPersistedVendor() {
when(credentialMapper.selectById(2L)).thenReturn(entity(2L,
"cred-valid", "ALIYUN"));
diff --git a/web/src/pages/ops/__tests__/auditPresentation.test.ts
b/web/src/pages/ops/__tests__/auditPresentation.test.ts
index b030e9a3d..cfb7af35d 100644
--- a/web/src/pages/ops/__tests__/auditPresentation.test.ts
+++ b/web/src/pages/ops/__tests__/auditPresentation.test.ts
@@ -116,6 +116,21 @@ describe('audit presentation helpers', () => {
]);
});
+ it('preserves commas inside nested configuration and quoted values', () => {
+ expect(
+ parseAuditDetail(
+ 'brokerAddr=10.0.0.1:10911, config={flushDiskType=SYNC_FLUSH,
fileReservedTime=72}, note="primary, synchronous"',
+ ),
+ ).toEqual([
+ { label: 'brokerAddr', value: '10.0.0.1:10911' },
+ {
+ label: 'config',
+ value: '{flushDiskType=SYNC_FLUSH, fileReservedTime=72}',
+ },
+ { label: 'note', value: '"primary, synchronous"' },
+ ]);
+ });
+
it('keeps free-form details intact when they are not key-value lists', () =>
{
expect(parseAuditDetail('Removed stale Proxy address
10.0.30.9:8081')).toEqual([
{ label: '', value: 'Removed stale Proxy address 10.0.30.9:8081' },
diff --git a/web/src/pages/ops/auditPresentation.ts
b/web/src/pages/ops/auditPresentation.ts
index 1980ce7c6..c19fd274d 100644
--- a/web/src/pages/ops/auditPresentation.ts
+++ b/web/src/pages/ops/auditPresentation.ts
@@ -272,7 +272,8 @@ export function parseAuditDetail(detail: string | null |
undefined): AuditDetail
const text = detail?.trim();
if (!text) return [];
- const parts = text.split(/\s*,\s*/).filter(Boolean);
+ const parts = splitTopLevelDetailFields(text);
+ if (!parts) return [{ label: '', value: text }];
if (parts.length <= 1) return [{ label: '', value: text }];
const tokens = parts.map((part) => {
@@ -290,3 +291,55 @@ export function parseAuditDetail(detail: string | null |
undefined): AuditDetail
}
return tokens as AuditDetailToken[];
}
+
+const DETAIL_DELIMITERS: Record<string, string> = {
+ '{': '}',
+ '[': ']',
+ '(': ')',
+};
+
+function splitTopLevelDetailFields(text: string): string[] | null {
+ const fields: string[] = [];
+ const expectedClosings: string[] = [];
+ let fieldStart = 0;
+ let quote = '';
+ let escaped = false;
+
+ for (let index = 0; index < text.length; index += 1) {
+ const char = text[index];
+ if (quote) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === '\\') {
+ escaped = true;
+ } else if (char === quote) {
+ quote = '';
+ }
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ quote = char;
+ continue;
+ }
+ const closing = DETAIL_DELIMITERS[char];
+ if (closing) {
+ expectedClosings.push(closing);
+ continue;
+ }
+ if (char === '}' || char === ']' || char === ')') {
+ if (expectedClosings.pop() !== char) return null;
+ continue;
+ }
+ if (char === ',' && expectedClosings.length === 0) {
+ const field = text.slice(fieldStart, index).trim();
+ if (field) fields.push(field);
+ fieldStart = index + 1;
+ }
+ }
+
+ if (quote || expectedClosings.length > 0) return null;
+ const field = text.slice(fieldStart).trim();
+ if (field) fields.push(field);
+ return fields;
+}