task-70: 历史清理改为 keyset 分页、小批量和短事务

This commit is contained in:
2026-08-30 19:48:59 +08:00
parent fb090bf80d
commit 9de4603484
4 changed files with 585 additions and 96 deletions
@@ -12,6 +12,11 @@ public class ModuleCleanupProperties {
private boolean enabled = true;
private String cron = "0 0 0 * * *";
private long retentionDays = 7;
/**
* keyset 分页每批处理的任务数:每批一个短事务,批内按 id 升序,
* 批间无行级排他锁占用;0/负值回退默认 500。
*/
private int batchSize = 500;
// SHOP_DATA_CRAWL keeps one per-shop daily workbook in its task service
// and must not be removed by the age-based sweep.
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN", "WITHDRAW", "APPEARANCE_PATENT", "SIMILAR_ASIN", "COLLECT_DATA"));
@@ -22,12 +22,12 @@ import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -38,12 +38,12 @@ import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
public class ModuleHistoryCleanupService {
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
private static final Duration CLEANUP_LOCK_TTL = Duration.ofHours(2);
private static final String COLLECT_DATA_MODULE_TYPE = "COLLECT_DATA";
private static final int DEFAULT_BATCH_SIZE = 500;
private final ModuleCleanupProperties moduleCleanupProperties;
private final FileTaskMapper fileTaskMapper;
@@ -57,15 +57,47 @@ public class ModuleHistoryCleanupService {
private final CollectDataItemMapper collectDataItemMapper;
private final DistributedJobLockService distributedJobLockService;
private final TransientPayloadDeleteOrchestrator transientPayloadDeleteOrchestrator;
private final TransactionTemplate transactionTemplate;
/**
* 单次清理最多收集的 payload 指针数:超过即截断(保底可重试),
* 单最多收集的 payload 指针数:超过即截断(保底可重试),
* 防止单任务行数异常巨大时无界收集造成内存增长。
*/
@Value("${aiimage.module-cleanup.max-collect-payloads:10000}")
private int maxCollectPayloadsPerRun = 10000;
@Transactional
public ModuleHistoryCleanupService(ModuleCleanupProperties moduleCleanupProperties,
FileTaskMapper fileTaskMapper,
FileResultMapper fileResultMapper,
TaskFileJobMapper taskFileJobMapper,
TaskResultItemMapper taskResultItemMapper,
TaskProgressSnapshotMapper taskProgressSnapshotMapper,
TaskResultPayloadMapper taskResultPayloadMapper,
TaskScopeStateMapper taskScopeStateMapper,
TaskChunkMapper taskChunkMapper,
CollectDataItemMapper collectDataItemMapper,
DistributedJobLockService distributedJobLockService,
TransientPayloadDeleteOrchestrator transientPayloadDeleteOrchestrator,
PlatformTransactionManager platformTransactionManager) {
this.moduleCleanupProperties = moduleCleanupProperties;
this.fileTaskMapper = fileTaskMapper;
this.fileResultMapper = fileResultMapper;
this.taskFileJobMapper = taskFileJobMapper;
this.taskResultItemMapper = taskResultItemMapper;
this.taskProgressSnapshotMapper = taskProgressSnapshotMapper;
this.taskResultPayloadMapper = taskResultPayloadMapper;
this.taskScopeStateMapper = taskScopeStateMapper;
this.taskChunkMapper = taskChunkMapper;
this.collectDataItemMapper = collectDataItemMapper;
this.distributedJobLockService = distributedJobLockService;
this.transientPayloadDeleteOrchestrator = transientPayloadDeleteOrchestrator;
this.transactionTemplate = new TransactionTemplate(platformTransactionManager);
}
/**
* 按 id 升序 keyset 分页拉取过期终态任务,每页一个小批量、一个短事务;
* 页间不持有事务与行锁,失败回滚当页并中止,未处理页可下次重试。
*/
@Scheduled(cron = "${aiimage.module-cleanup.cron:0 0 0 * * *}")
public void cleanupConfiguredModules() {
DistributedJobLockService.LockHandle lockHandle =
@@ -83,101 +115,129 @@ public class ModuleHistoryCleanupService {
return;
}
LocalDateTime cutoff = LocalDateTime.now().minusDays(Math.max(0, moduleCleanupProperties.getRetentionDays()));
int batchSize = moduleCleanupProperties.getBatchSize();
if (batchSize <= 0) {
batchSize = DEFAULT_BATCH_SIZE;
}
List<FileTaskEntity> moduleTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes)
.select(FileTaskEntity::getId, FileTaskEntity::getModuleType, FileTaskEntity::getStatus,
FileTaskEntity::getUpdatedAt, FileTaskEntity::getFinishedAt));
List<Long> cleanupTaskIds = new ArrayList<>();
List<Long> cleanupCollectDataTaskIds = new ArrayList<>();
long cursor = 0L;
int totalBatches = 0;
int totalDeletedTasks = 0;
int totalCollectedPointers = 0;
List<Long> skippedActiveTaskIds = new ArrayList<>();
List<Long> skippedRetainedTaskIds = new ArrayList<>();
for (FileTaskEntity task : moduleTasks) {
if (task == null || task.getId() == null) {
continue;
while (true) {
List<FileTaskEntity> page = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes)
.gt(FileTaskEntity::getId, cursor)
.orderByAsc(FileTaskEntity::getId)
.last("LIMIT " + batchSize));
if (page.isEmpty()) {
break;
}
if (!isTerminalStatus(task.getStatus())) {
skippedActiveTaskIds.add(task.getId());
continue;
}
if (isExpired(task, cutoff)) {
cleanupTaskIds.add(task.getId());
if (COLLECT_DATA_MODULE_TYPE.equals(task.getModuleType())) {
cleanupCollectDataTaskIds.add(task.getId());
long pageMaxId = cursor;
List<Long> batchTaskIds = new ArrayList<>();
List<Long> batchCollectDataTaskIds = new ArrayList<>();
for (FileTaskEntity task : page) {
if (task == null || task.getId() == null) {
continue;
}
pageMaxId = Math.max(pageMaxId, task.getId());
if (!isTerminalStatus(task.getStatus())) {
skippedActiveTaskIds.add(task.getId());
continue;
}
if (isExpired(task, cutoff)) {
batchTaskIds.add(task.getId());
if (COLLECT_DATA_MODULE_TYPE.equals(task.getModuleType())) {
batchCollectDataTaskIds.add(task.getId());
}
} else {
skippedRetainedTaskIds.add(task.getId());
}
} else {
skippedRetainedTaskIds.add(task.getId());
}
if (!batchTaskIds.isEmpty()) {
final List<Long> taskIds = batchTaskIds;
final List<Long> collectDataTaskIds = batchCollectDataTaskIds;
final List<String> types = moduleTypes;
final List<String> collected = new ArrayList<>();
transactionTemplate.executeWithoutResult(status -> {
collected.addAll(collectPayloadPointers(types, taskIds, maxCollectPayloadsPerRun));
int deletedRows = deleteRows(types, taskIds, collectDataTaskIds);
submitAndFlush(collected);
log.info("[module-cleanup] batch: taskIds={}, deletedRows={}, collectedPointers={}",
taskIds, deletedRows, collected.size());
});
totalBatches++;
totalDeletedTasks += taskIds.size();
totalCollectedPointers += collected.size();
}
if (pageMaxId <= cursor) {
log.warn("[module-cleanup] keyset cursor did not advance, abort loop cursor={}", cursor);
break;
}
cursor = pageMaxId;
}
if (cleanupTaskIds.isEmpty()) {
log.info("[module-cleanup] skipped: moduleTypes={}, retentionDays={}, cutoff={}, activeTaskIds={}, retainedTaskIds={}, reason=no-expired-terminal-tasks",
moduleTypes, moduleCleanupProperties.getRetentionDays(), cutoff, skippedActiveTaskIds, skippedRetainedTaskIds);
return;
}
// 行删除前先收集将随行删除的 transient payload 指针(上限截断),
// 全部删除成功后再提交清理队列并 flush,避免删除失败留下已提交的孤儿清理。
List<String> collectedPayloadPointers = collectPayloadPointers(cleanupTaskIds, maxCollectPayloadsPerRun);
int deletedFileJobs = taskFileJobMapper.delete(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getModuleType, moduleTypes)
.in(TaskFileJobEntity::getTaskId, cleanupTaskIds));
int deletedResultItems = taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.in(TaskResultItemEntity::getModuleType, moduleTypes)
.in(TaskResultItemEntity::getTaskId, cleanupTaskIds));
int deletedProgressSnapshots = taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
.in(TaskProgressSnapshotEntity::getModuleType, moduleTypes)
.in(TaskProgressSnapshotEntity::getTaskId, cleanupTaskIds));
int deletedResultPayloads = taskResultPayloadMapper.delete(new LambdaQueryWrapper<TaskResultPayloadEntity>()
.in(TaskResultPayloadEntity::getModuleType, moduleTypes)
.in(TaskResultPayloadEntity::getTaskId, cleanupTaskIds));
int deletedScopeStates = taskScopeStateMapper.delete(new LambdaQueryWrapper<TaskScopeStateEntity>()
.in(TaskScopeStateEntity::getModuleType, moduleTypes)
.in(TaskScopeStateEntity::getTaskId, cleanupTaskIds));
int deletedChunks = taskChunkMapper.delete(new LambdaQueryWrapper<TaskChunkEntity>()
.in(TaskChunkEntity::getModuleType, moduleTypes)
.in(TaskChunkEntity::getTaskId, cleanupTaskIds));
int deletedCollectDataItems = 0;
if (!cleanupCollectDataTaskIds.isEmpty()) {
deletedCollectDataItems = collectDataItemMapper.delete(new LambdaQueryWrapper<CollectDataItemEntity>()
.in(CollectDataItemEntity::getTaskId, cleanupCollectDataTaskIds));
}
int deletedResults = fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.in(FileResultEntity::getModuleType, moduleTypes)
.in(FileResultEntity::getTaskId, cleanupTaskIds));
int resetTasks = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds)
.set(FileTaskEntity::getResultJson, null)
.set(FileTaskEntity::getRequestJson, null)
.set(FileTaskEntity::getErrorMessage, null));
int deletedTasks = fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds));
// 行已全部删除,此时引用检查将判定这些指针不再被引用 → 提交清理队列并 flush。
if (!collectedPayloadPointers.isEmpty()) {
transientPayloadDeleteOrchestrator.submitDeletes(collectedPayloadPointers);
transientPayloadDeleteOrchestrator.flushPendingDeletes();
}
log.info("[module-cleanup] completed: moduleTypes={}, retentionDays={}, cutoff={}, deletedFileJobs={}, deletedResultItems={}, deletedProgressSnapshots={}, deletedResultPayloads={}, deletedScopeStates={}, deletedChunks={}, deletedCollectDataItems={}, deletedResults={}, resetTasks={}, deletedTasks={}, collectedPayloadPointers={}, skippedActiveTaskIds={}, retainedTaskIds={}",
log.info("[module-cleanup] completed: moduleTypes={}, retentionDays={}, cutoff={}, batches={}, deletedTasks={}, collectedPayloadPointers={}, skippedActiveTaskIds={}, retainedTaskIds={}",
moduleTypes, moduleCleanupProperties.getRetentionDays(), cutoff,
deletedFileJobs, deletedResultItems, deletedProgressSnapshots, deletedResultPayloads,
deletedScopeStates, deletedChunks, deletedCollectDataItems, deletedResults, resetTasks, deletedTasks,
collectedPayloadPointers.size(), skippedActiveTaskIds, skippedRetainedTaskIds);
totalBatches, totalDeletedTasks, totalCollectedPointers,
skippedActiveTaskIds, skippedRetainedTaskIds);
}
}
/** 一个事务内完成:收集指针 → 删除本批行 → 行删完后提交清理队列并 flush。 */
private int deleteRows(List<String> moduleTypes, List<Long> cleanupTaskIds, List<Long> collectDataTaskIds) {
taskFileJobMapper.delete(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getModuleType, moduleTypes)
.in(TaskFileJobEntity::getTaskId, cleanupTaskIds));
taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.in(TaskResultItemEntity::getModuleType, moduleTypes)
.in(TaskResultItemEntity::getTaskId, cleanupTaskIds));
taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
.in(TaskProgressSnapshotEntity::getModuleType, moduleTypes)
.in(TaskProgressSnapshotEntity::getTaskId, cleanupTaskIds));
taskResultPayloadMapper.delete(new LambdaQueryWrapper<TaskResultPayloadEntity>()
.in(TaskResultPayloadEntity::getModuleType, moduleTypes)
.in(TaskResultPayloadEntity::getTaskId, cleanupTaskIds));
taskScopeStateMapper.delete(new LambdaQueryWrapper<TaskScopeStateEntity>()
.in(TaskScopeStateEntity::getModuleType, moduleTypes)
.in(TaskScopeStateEntity::getTaskId, cleanupTaskIds));
taskChunkMapper.delete(new LambdaQueryWrapper<TaskChunkEntity>()
.in(TaskChunkEntity::getModuleType, moduleTypes)
.in(TaskChunkEntity::getTaskId, cleanupTaskIds));
if (!collectDataTaskIds.isEmpty()) {
collectDataItemMapper.delete(new LambdaQueryWrapper<CollectDataItemEntity>()
.in(CollectDataItemEntity::getTaskId, collectDataTaskIds));
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.in(FileResultEntity::getModuleType, moduleTypes)
.in(FileResultEntity::getTaskId, cleanupTaskIds));
fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds)
.set(FileTaskEntity::getResultJson, null)
.set(FileTaskEntity::getRequestJson, null)
.set(FileTaskEntity::getErrorMessage, null));
return fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, cleanupTaskIds));
}
private void submitAndFlush(List<String> collectedPointers) {
if (collectedPointers.isEmpty()) {
return;
}
transientPayloadDeleteOrchestrator.submitDeletes(collectedPointers);
transientPayloadDeleteOrchestrator.flushPendingDeletes();
}
private boolean isExpired(FileTaskEntity task, LocalDateTime cutoff) {
LocalDateTime completedAt = task.getFinishedAt() != null ? task.getFinishedAt() : task.getUpdatedAt();
return completedAt != null && !completedAt.isAfter(cutoff);
@@ -195,16 +255,16 @@ public class ModuleHistoryCleanupService {
* scope_state.parsedPayloadJson / stateJson),去重并保持稳定顺序;
* 达到 {@code max} 上限即截断,防止异常巨大的任务行数引发无界收集。
*/
private List<String> collectPayloadPointers(List<Long> cleanupTaskIds, int max) {
private List<String> collectPayloadPointers(List<String> moduleTypes, List<Long> cleanupTaskIds, int max) {
java.util.Set<String> pointers = new java.util.LinkedHashSet<>();
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.in(TaskChunkEntity::getModuleType, moduleCleanupProperties.getModuleTypes())
.in(TaskChunkEntity::getModuleType, moduleTypes)
.in(TaskChunkEntity::getTaskId, cleanupTaskIds));
for (TaskChunkEntity chunk : chunks) {
collectPointer(pointers, chunk.getPayloadJson(), max);
}
List<TaskScopeStateEntity> scopeStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.in(TaskScopeStateEntity::getModuleType, moduleCleanupProperties.getModuleTypes())
.in(TaskScopeStateEntity::getModuleType, moduleTypes)
.in(TaskScopeStateEntity::getTaskId, cleanupTaskIds));
for (TaskScopeStateEntity scopeState : scopeStates) {
collectPointer(pointers, scopeState.getParsedPayloadJson(), max);