From 2de58b7afc4f353d9bea8d7181ad3ed0eebf2492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 30 Aug 2026 19:13:43 +0800 Subject: [PATCH] =?UTF-8?q?task-68:=20payload=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=94=B9=E4=B8=BA=E6=89=B9=E9=87=8F=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=A3=80=E6=9F=A5=E4=B8=8E=E5=BC=82=E6=AD=A5=E7=89=A9?= =?UTF-8?q?=E7=90=86=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TransientPayloadDeleteOrchestrator:提交去重入队(maxPendingDeletes 上限), flush 时一次性 IN 批量反查 biz_task_chunk / biz_task_scope_state, 未引用对象交由后台线程池异步物理删除(剥 rustfs: 前缀) - 幂等:重复提交只入队一次;保守:查询失败整批保留可重试,不误删 - 空值/非指针忽略,本地/OSS 指针不在批量删除范围(各有归属与清理路径) --- .../TransientPayloadDeleteOrchestrator.java | 205 ++++++++++++++++ ...ransientPayloadDeleteOrchestratorTest.java | 221 ++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestrator.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestratorTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestrator.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestrator.java new file mode 100644 index 00000000..8efc6d2b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestrator.java @@ -0,0 +1,205 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; + +/** + * P2-9 扩展(task-68):payload 引用删除改为批量引用检查与异步物理删除。 + *

调用方把待删 payload(指针或 JSON 编码指针)批量提交,本组件去重入队; + * {@link #flushPendingDeletes()} 对整批 pending 一次性 IN 反查 + * biz_task_chunk / biz_task_scope_state(比逐条两次查询少一个数量级的 DB 往返), + * 确认不再被引用的对象交由后台线程池异步物理删除,不阻塞业务线程。 + *

幂等:重复提交相同对象只入队一次;保守:引用检查失败时本批保留, + * 调用方(如周期清理任务)可再次 flush 重试。 + */ +@Service +@Slf4j +public class TransientPayloadDeleteOrchestrator { + + private static final String RUSTFS_POINTER_PREFIX = "rustfs:"; + + private final TransientPayloadStorageService transientPayloadStorageService; + private final RustfsObjectStorageService rustfsObjectStorageService; + private final TaskChunkMapper taskChunkMapper; + private final TaskScopeStateMapper taskScopeStateMapper; + private final ObjectMapper objectMapper; + private final ExecutorService asyncDeleteExecutor; + + private final Set pendingPointers = new LinkedHashSet<>(); + + @Value("${aiimage.transient-storage.max-pending-deletes:1000}") + private long maxPendingDeletes = 1000; + + public TransientPayloadDeleteOrchestrator(TransientPayloadStorageService transientPayloadStorageService, + RustfsObjectStorageService rustfsObjectStorageService, + TaskChunkMapper taskChunkMapper, + TaskScopeStateMapper taskScopeStateMapper, + ObjectMapper objectMapper, + ExecutorService asyncDeleteExecutor) { + this.transientPayloadStorageService = transientPayloadStorageService; + this.rustfsObjectStorageService = rustfsObjectStorageService; + this.taskChunkMapper = taskChunkMapper; + this.taskScopeStateMapper = taskScopeStateMapper; + this.objectMapper = objectMapper; + this.asyncDeleteExecutor = asyncDeleteExecutor; + } + + /** + * 批量提交待删 payload 值(指针或 JSON 编码指针)。空值与非指针值忽略; + * 已在 pending 中的对象幂等跳过;pending 达到上限后拒绝新提交并返回实际入队数。 + */ + public int submitDeletes(List values) { + if (values == null || values.isEmpty()) { + return 0; + } + synchronized (pendingPointers) { + int accepted = 0; + for (String value : values) { + String pointer = transientPayloadStorageService.extractPointer(value); + if (pointer == null) { + continue; + } + if (pendingPointers.size() >= maxPendingDeletes) { + log.warn("[transient-payload] delete queue full, drop {} pointer={}", "submit", pointer); + break; + } + if (pendingPointers.add(pointer)) { + accepted++; + } + } + return accepted; + } + } + + /** + * 对 pending 中的对象批量做引用检查,未引用的异步物理删除,返回本批删除数。 + * 引用检查异常时保守跳过整批(不删),调用方可再次 flush 重试。 + */ + public int flushPendingDeletes() { + List batch; + synchronized (pendingPointers) { + if (pendingPointers.isEmpty()) { + return 0; + } + batch = new ArrayList<>(pendingPointers); + pendingPointers.clear(); + } + try { + Set stillReferenced = batchReferencedPointers(batch); + List toDelete = new ArrayList<>(batch); + toDelete.removeAll(stillReferenced); + if (!toDelete.isEmpty()) { + asyncDeleteExecutor.submit(() -> deleteObjects(toDelete)); + } + if (!stillReferenced.isEmpty()) { + log.info("[transient-payload] skip delete, still referenced count={}", stillReferenced.size()); + } + return toDelete.size(); + } catch (Exception ex) { + log.warn("[transient-payload] batch reference check failed, keep pending count={} err={}", + batch.size(), ex.getMessage()); + synchronized (pendingPointers) { + pendingPointers.addAll(batch); + } + return 0; + } + } + + public int pendingCount() { + synchronized (pendingPointers) { + return pendingPointers.size(); + } + } + + private void deleteObjects(List pointers) { + for (String pointer : pointers) { + if (pointer == null || !pointer.startsWith(RUSTFS_POINTER_PREFIX)) { + // 本地/OSS 指针有实例归属与其它清理路径,不在此批量删除范围。 + continue; + } + String objectKey = pointer.substring(RUSTFS_POINTER_PREFIX.length()); + try { + rustfsObjectStorageService.deleteObject(objectKey); + } catch (Exception ex) { + log.warn("[transient-payload] async delete failed objectKey={} err={}", + objectKey, ex.getMessage()); + } + } + } + + /** 批量 IN 反查两张引用表,返回仍被引用的指针集合(查询异常抛给调用方)。 */ + private Set batchReferencedPointers(List pointers) { + List jsonEncoded = pointers.stream() + .map(this::jsonEncodePointer) + .filter(java.util.Objects::nonNull) + .toList(); + List candidates = new ArrayList<>(pointers); + candidates.addAll(jsonEncoded); + + Set referenced = new LinkedHashSet<>(); + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .in(TaskChunkEntity::getPayloadJson, candidates)); + for (TaskChunkEntity chunk : chunks) { + referenced.addAll(matchingPointers(chunk.getPayloadJson(), pointers)); + } + List scopeStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .and(w -> w.in(TaskScopeStateEntity::getParsedPayloadJson, candidates) + .or() + .in(TaskScopeStateEntity::getStateJson, candidates))); + for (TaskScopeStateEntity scopeState : scopeStates) { + referenced.addAll(matchingPointers(scopeState.getParsedPayloadJson(), pointers)); + referenced.addAll(matchingPointers(scopeState.getStateJson(), pointers)); + } + return referenced; + } + + private Set matchingPointers(String dbValue, List pointers) { + Set matches = new LinkedHashSet<>(); + if (dbValue == null || dbValue.isBlank()) { + return matches; + } + String candidate = dbValue.trim(); + for (int i = 0; i < 4; i++) { + String pointer = transientPayloadStorageService.extractPointer(candidate); + if (pointer != null && pointers.contains(pointer)) { + matches.add(pointer); + } + String decoded; + try { + decoded = objectMapper.readValue(candidate, String.class); + } catch (Exception ex) { + break; + } + if (decoded == null || decoded.equals(candidate)) { + break; + } + candidate = decoded.trim(); + } + return matches; + } + + private String jsonEncodePointer(String pointer) { + try { + return objectMapper.writeValueAsString(pointer); + } catch (Exception ex) { + return null; + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestratorTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestratorTest.java new file mode 100644 index 00000000..cecc425d --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TransientPayloadDeleteOrchestratorTest.java @@ -0,0 +1,221 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.config.TransientStorageProperties; +import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Task 68:payload 引用删除改为批量引用检查与异步物理删除。 + * TransientPayloadDeleteOrchestrator 把待删 pointer 去重入队,flush 时一次性 + * IN 批量反查 biz_task_chunk / biz_task_scope_state(而非逐条两次查询), + * 未引用的对象交由后台线程池异步物理删除;重复提交幂等、失败保守保留。 + */ +class TransientPayloadDeleteOrchestratorTest { + + @TempDir + Path tempDir; + + private TaskChunkMapper chunkMapper; + private TaskScopeStateMapper scopeStateMapper; + private RustfsObjectStorageService rustfs; + private OssStorageService oss; + private TransientPayloadStorageService storage; + private TransientPayloadDeleteOrchestrator orchestrator; + private ExecutorService executor; + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + TaskChunkEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + TaskScopeStateEntity.class); + } + + @BeforeEach + void setUp() { + rustfs = mock(RustfsObjectStorageService.class); + oss = mock(OssStorageService.class); + chunkMapper = mock(TaskChunkMapper.class); + scopeStateMapper = mock(TaskScopeStateMapper.class); + TransientStorageProperties properties = new TransientStorageProperties(); + properties.setEnabled(true); + StorageProperties storageProperties = new StorageProperties(); + storageProperties.setLocalTempDir(tempDir.toString()); + storage = new TransientPayloadStorageService( + properties, storageProperties, rustfs, oss, + new ObjectMapper(), new InstanceMetadata("test-instance"), + chunkMapper, scopeStateMapper); + executor = Executors.newFixedThreadPool(2); + orchestrator = new TransientPayloadDeleteOrchestrator( + storage, rustfs, chunkMapper, scopeStateMapper, new ObjectMapper(), executor); + ReflectionTestUtils.setField(orchestrator, "maxPendingDeletes", 100L); + } + + private static String pointer(int taskId) { + return "rustfs:task-parsed/test/" + taskId + "/scope/latest.json"; + } + + private void stubNoReferences() { + when(chunkMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(scopeStateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + } + + private CountDownLatch latchOnDelete(int count) { + CountDownLatch latch = new CountDownLatch(count); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(rustfs).deleteObject(anyString()); + return latch; + } + + @Test + void test_task_068_payload_normal_default_path() throws Exception { + // 默认路径:批量提交未引用 pointer,flush 后全部异步物理删除。 + stubNoReferences(); + int accepted = orchestrator.submitDeletes(List.of(pointer(1), pointer(2))); + assertEquals(2, accepted); + + CountDownLatch done = latchOnDelete(2); + assertEquals(2, orchestrator.flushPendingDeletes(), "两个对象提交物理删除"); + assertTrue(done.await(2, TimeUnit.SECONDS), "异步删除完成"); + verify(rustfs, times(2)).deleteObject(anyString()); + verify(chunkMapper).selectList(any(LambdaQueryWrapper.class)); + verify(scopeStateMapper).selectList(any(LambdaQueryWrapper.class)); + } + + @Test + void test_task_068_payload_normal_multiple_items() throws Exception { + // 批量场景:5 个对象批量检查,1 个仍被 scope_state 引用则跳过,其余 4 个删除。 + stubNoReferences(); + TaskScopeStateEntity referenced = new TaskScopeStateEntity(); + referenced.setParsedPayloadJson("\"rustfs:task-parsed/test/3/scope/latest.json\""); + when(scopeStateMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(referenced)); + + List values = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + values.add(pointer(i)); + } + assertEquals(5, orchestrator.submitDeletes(values)); + CountDownLatch done = latchOnDelete(4); + assertEquals(4, orchestrator.flushPendingDeletes(), "仅删除未引用对象"); + assertTrue(done.await(2, TimeUnit.SECONDS)); + verify(rustfs, times(4)).deleteObject(anyString()); + verify(rustfs, never()).deleteObject("task-parsed/test/3/scope/latest.json"); + } + + @Test + void test_task_068_payload_normal_repeated_operation_is_idempotent() throws Exception { + // 幂等:重复提交同一对象只入队一次、只删除一次,不产生重复物理删除请求。 + stubNoReferences(); + assertEquals(1, orchestrator.submitDeletes(List.of(pointer(1)))); + assertEquals(0, orchestrator.submitDeletes(List.of(pointer(1))), "重复提交被去重"); + + CountDownLatch done = latchOnDelete(1); + assertEquals(1, orchestrator.flushPendingDeletes()); + assertTrue(done.await(2, TimeUnit.SECONDS)); + verify(rustfs, times(1)).deleteObject(anyString()); + } + + @Test + void test_task_068_payload_boundary_empty_input() throws Exception { + // 空输入:空集合/含空值集合不入队、不查询、不删除。 + assertEquals(0, orchestrator.submitDeletes(List.of())); + assertEquals(0, orchestrator.submitDeletes(null)); + assertEquals(0, orchestrator.submitDeletes(java.util.Arrays.asList("", " ", null))); + + assertEquals(0, orchestrator.flushPendingDeletes()); + verify(chunkMapper, never()).selectList(any()); + verify(scopeStateMapper, never()).selectList(any()); + verify(rustfs, never()).deleteObject(anyString()); + } + + @Test + void test_task_068_payload_boundary_single_item() throws Exception { + // 单元素:单个对象不依赖批量路径,检查一次删除一次。 + stubNoReferences(); + assertEquals(1, orchestrator.submitDeletes(List.of(pointer(7)))); + CountDownLatch done = latchOnDelete(1); + assertEquals(1, orchestrator.flushPendingDeletes()); + assertTrue(done.await(2, TimeUnit.SECONDS)); + verify(rustfs).deleteObject("task-parsed/test/7/scope/latest.json"); + } + + @Test + void test_task_068_payload_boundary_limit_and_overflow() throws Exception { + // 上限/超限:pending 队列达到 maxPendingDeletes 后拒绝新提交,不发生无界内存增长。 + stubNoReferences(); + ReflectionTestUtils.setField(orchestrator, "maxPendingDeletes", 3L); + List values = List.of(pointer(1), pointer(2), pointer(3), pointer(4), pointer(5)); + assertEquals(3, orchestrator.submitDeletes(values), "超过上限的提交被拒绝"); + + CountDownLatch done = latchOnDelete(3); + assertEquals(3, orchestrator.flushPendingDeletes()); + assertTrue(done.await(2, TimeUnit.SECONDS)); + verify(rustfs, times(3)).deleteObject(anyString()); + } + + @Test + void test_task_068_payload_invalid_input_rejected() { + // 非法参数:非指针值不入队,不发起引用检查。 + stubNoReferences(); + assertEquals(0, orchestrator.submitDeletes(List.of("not-a-pointer", "plain text"))); + assertEquals(0, orchestrator.flushPendingDeletes()); + verify(chunkMapper, never()).selectList(any()); + } + + @Test + void test_task_068_payload_dependency_failure_releases_resources() throws Exception { + // 依赖失败:引用检查查询异常时保守不删、pending 保留可重试;恢复后删除成功。 + when(chunkMapper.selectList(any(LambdaQueryWrapper.class))) + .thenThrow(new RuntimeException("db down")); + assertEquals(1, orchestrator.submitDeletes(List.of(pointer(1)))); + + assertEquals(0, orchestrator.flushPendingDeletes(), "查询失败保守不删"); + verify(rustfs, never()).deleteObject(anyString()); + + stubNoReferences(); + CountDownLatch done = latchOnDelete(1); + assertEquals(1, orchestrator.flushPendingDeletes(), "恢复后重试成功"); + assertTrue(done.await(2, TimeUnit.SECONDS)); + verify(rustfs).deleteObject("task-parsed/test/1/scope/latest.json"); + } +}