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 9b02adb4d fix(audit): disclose a cleanup that stopped at its per-call 
ceiling (#7148)
9b02adb4d is described below

commit 9b02adb4ddc6bb87c45bca8d1fa4e07aaa5e19b8
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 26 20:52:43 2026 +0800

    fix(audit): disclose a cleanup that stopped at its per-call ceiling (#7148)
    
    * fix(audit): disclose a cleanup that stopped at its per-call ceiling 
(#5976)
    The cleanup endpoint deletes at most 500 rows per batch in 20 batches and 
answered with the
    deleted count alone, so a sweep that ran out of budget looked exactly like 
a complete one and the
    operator was told the retention window had been applied while older rows 
survived.
    Cleanup now answers with AuditCleanupResultVO: the same deleted count plus 
a truncated flag derived
    from the per-call row ceiling, and the service logs a warning naming the 
ceiling when it is hit.
    Fixes #5976
    * docs(api-spec): disclose the cleanup ceiling and end the VO with a newline
    The cleanup response gained a truncated flag meaning the sweep stopped at 
its
    per-call ceiling; the spec still documented only the deleted count, so a 
caller
    had no way to learn the cleanup was partial. Also add the missing trailing
    newline to AuditCleanupResultVO.
---
 docs/api-spec.md                                   |  3 +-
 .../studio/ops/audit/AuditCleanupResultVO.java     | 40 ++++++++++++++++++++++
 .../rocketmq/studio/ops/audit/AuditController.java |  7 ++--
 .../rocketmq/studio/ops/audit/AuditService.java    | 18 ++++++++--
 .../studio/ops/audit/AuditControllerTest.java      | 20 +++++++++--
 .../studio/ops/audit/AuditServiceTest.java         | 17 +++++++--
 6 files changed, 92 insertions(+), 13 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index eaac7d7f5..622640a9a 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -2560,7 +2560,8 @@ POST /api/audit-logs/cleanup
 
 | 字段 | 类型 | 说明 |
 |------|------|------|
-| `deleted` | `number` | 清理数量 |
+| `deleted` | `number` | 本次清理删除的记录数 |
+| `truncated` | `boolean` | 为 `true` 表示本次清理达到单次上限即停、仍有更早的记录留存;重复调用直到返回 `false` 
即清完 |
 
 ---
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditCleanupResultVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditCleanupResultVO.java
new file mode 100644
index 000000000..f53916c6b
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditCleanupResultVO.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.audit;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Outcome of one audit-log cleanup call. The deleted count alone cannot tell 
a complete sweep from
+ * one that ran out of budget, so the answer carries the ceiling signal next 
to it.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuditCleanupResultVO {
+    /** Rows removed by this call. */
+    private int deleted;
+    /**
+     * True when the sweep stopped at its per-call ceiling, which means rows 
older than the retention
+     * window may still be stored: the caller must not report a complete 
cleanup.
+     */
+    private boolean truncated;
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
index 08d964119..7f3311828 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
@@ -27,8 +27,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
-import java.util.Map;
-
 @RestController
 @RequestMapping("/api/audit-logs")
 @RequiredArgsConstructor
@@ -90,9 +88,8 @@ public class AuditController {
     }
 
     @PostMapping("/cleanup")
-    public Result<Map<String, Integer>> cleanupLogs(@Valid 
@RequestBody(required = false) AuditCleanupDTO request) {
+    public Result<AuditCleanupResultVO> cleanupLogs(@Valid 
@RequestBody(required = false) AuditCleanupDTO request) {
         int beforeDays = request == null || request.getBeforeDays() == null ? 
30 : request.getBeforeDays();
-        int deleted = auditService.cleanupLogs(beforeDays);
-        return Result.ok(Map.of("deleted", deleted));
+        return Result.ok(auditService.cleanupLogs(beforeDays));
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
index a4d2d345d..d1a6a5582 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
@@ -43,6 +43,12 @@ public class AuditService {
     private static final int MAX_EXPORT_RECORDS = 10_000;
     private static final int CLEANUP_BATCH_SIZE = 500;
     private static final int CLEANUP_MAX_BATCHES = 20;
+    /**
+     * Most rows one cleanup call can delete: {@code CLEANUP_BATCH_SIZE} rows 
in each of
+     * {@code CLEANUP_MAX_BATCHES} batches. Reaching it means the sweep ran 
out of budget, not that
+     * the retention window was applied.
+     */
+    private static final int CLEANUP_ROW_CEILING = CLEANUP_BATCH_SIZE * 
CLEANUP_MAX_BATCHES;
     private static final String CSV_COLUMNS =
             
"operator,operationType,resourceType,target,clusterId,detail,result,errorMessage";
 
@@ -181,7 +187,7 @@ public class AuditService {
         log.info("Audit recorded: {} on {} -> {}", operationType, target, 
result);
     }
 
-    public int cleanupLogs(int beforeDays) {
+    public AuditCleanupResultVO cleanupLogs(int beforeDays) {
         if (beforeDays <= 0) {
             throw new BusinessException(400, "beforeDays must be greater than 
0");
         }
@@ -190,7 +196,15 @@ public class AuditService {
         }
         log.info("Cleaning up audit logs older than {} days", beforeDays);
         LocalDateTime cutoff = LocalDateTime.now().minusDays(beforeDays);
-        return auditRepository.deleteBefore(cutoff, CLEANUP_BATCH_SIZE, 
CLEANUP_MAX_BATCHES);
+        int deleted = auditRepository.deleteBefore(cutoff, CLEANUP_BATCH_SIZE, 
CLEANUP_MAX_BATCHES);
+        // Equal to the ceiling means the delete loop stopped because it ran 
out of batches, so rows
+        // older than the cutoff may still be stored. Reporting the count 
alone hid that.
+        boolean truncated = deleted >= CLEANUP_ROW_CEILING;
+        if (truncated) {
+            log.warn("Audit cleanup older than {} days stopped at the {}-row 
ceiling after deleting {} rows; "
+                    + "older rows may remain", beforeDays, 
CLEANUP_ROW_CEILING, deleted);
+        }
+        return 
AuditCleanupResultVO.builder().deleted(deleted).truncated(truncated).build();
     }
 
     private void validatePagination(int page, int pageSize) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
index 2282242e5..3e7345097 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
@@ -50,6 +50,9 @@ import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 @Import(LegacyJackson2Config.class)
 class AuditControllerTest extends WebMvcAuthTestSupport {
 
+    private static AuditCleanupResultVO cleanupResult(int deleted, boolean 
truncated) {
+        return 
AuditCleanupResultVO.builder().deleted(deleted).truncated(truncated).build();
+    }
     @Autowired
     private MockMvc mockMvc;
 
@@ -95,7 +98,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
 
     @Test
     void cleanupLogsShouldUseProvidedRetention() throws Exception {
-        when(auditService.cleanupLogs(90)).thenReturn(7);
+        when(auditService.cleanupLogs(90)).thenReturn(cleanupResult(7, false));
 
         mockMvc.perform(post("/api/audit-logs/cleanup")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -107,9 +110,20 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
         verify(auditService).cleanupLogs(90);
     }
 
+    @Test
+    void cleanupLogsShouldDiscloseASweepThatHitItsCeilingTest() throws 
Exception {
+        when(auditService.cleanupLogs(90)).thenReturn(cleanupResult(10_000, 
true));
+
+        mockMvc.perform(post("/api/audit-logs/cleanup")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        
.content(objectMapper.writeValueAsString(Map.of("beforeDays", 90))))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.deleted").value(10_000))
+                .andExpect(jsonPath("$.data.truncated").value(true));
+    }
     @Test
     void cleanupLogsShouldDefaultRetentionWhenBodyIsEmpty() throws Exception {
-        when(auditService.cleanupLogs(30)).thenReturn(3);
+        when(auditService.cleanupLogs(30)).thenReturn(cleanupResult(3, false));
 
         mockMvc.perform(post("/api/audit-logs/cleanup"))
                 .andExpect(status().isOk())
@@ -120,7 +134,7 @@ class AuditControllerTest extends WebMvcAuthTestSupport {
 
     @Test
     void cleanupLogsShouldDefaultRetentionWhenBeforeDaysIsMissing() throws 
Exception {
-        when(auditService.cleanupLogs(30)).thenReturn(3);
+        when(auditService.cleanupLogs(30)).thenReturn(cleanupResult(3, false));
 
         mockMvc.perform(post("/api/audit-logs/cleanup")
                         .contentType(MediaType.APPLICATION_JSON)
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
index 64c5bd063..4cbe5bd10 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
@@ -312,12 +312,25 @@ class AuditServiceTest {
     void cleanupLogsUsesBoundedRepositoryBatchesTest() {
         when(auditRepository.deleteBefore(any(LocalDateTime.class), eq(500), 
eq(20))).thenReturn(500);
 
-        int deleted = auditService.cleanupLogs(90);
+        AuditCleanupResultVO result = auditService.cleanupLogs(90);
 
-        assertThat(deleted).isEqualTo(500);
+        assertThat(result.getDeleted()).isEqualTo(500);
+        assertThat(result.isTruncated()).isFalse();
         verify(auditRepository).deleteBefore(any(LocalDateTime.class), 
eq(500), eq(20));
     }
 
+    @Test
+    void cleanupLogsReportsTheCeilingInsteadOfACompleteSweepTest() {
+        // 500 rows in each of the 20 batches is the most one call can remove, 
so a deletion count
+        // equal to that ceiling means the sweep ran out of budget and older 
rows may still exist.
+        when(auditRepository.deleteBefore(any(LocalDateTime.class), eq(500), 
eq(20))).thenReturn(10_000);
+
+        AuditCleanupResultVO result = auditService.cleanupLogs(90);
+
+        assertThat(result.getDeleted()).isEqualTo(10_000);
+        assertThat(result.isTruncated()).isTrue();
+    }
+
     @Test
     void queryLogsShouldTrimSearchTermBeforeDelegating() {
         auditService.queryLogs(1, 10, "  ops  ", null, null, null, null, null, 
false,

Reply via email to