task-63: 进度快照写入最小更新间隔节流(终态豁免+删除清理跟踪)
This commit is contained in:
+39
@@ -7,19 +7,28 @@ import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TaskProgressSnapshotService {
|
||||
|
||||
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
|
||||
|
||||
private final TaskProgressSnapshotMapper taskProgressSnapshotMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ConcurrentHashMap<String, Long> lastWriteAtMillis = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${aiimage.task-progress-snapshot.min-update-interval-ms:500}")
|
||||
private long minUpdateIntervalMillis = 500;
|
||||
|
||||
@Transactional
|
||||
public void save(Long taskId,
|
||||
@@ -54,6 +63,7 @@ public class TaskProgressSnapshotService {
|
||||
entity.setUpdatedAt(now);
|
||||
try {
|
||||
taskProgressSnapshotMapper.insert(entity);
|
||||
touchLastWrite(taskId, moduleType, status);
|
||||
return;
|
||||
} catch (DuplicateKeyException ignored) {
|
||||
existing = find(taskId, moduleType);
|
||||
@@ -66,6 +76,9 @@ public class TaskProgressSnapshotService {
|
||||
currentScopeKey, message, snapshotJson)) {
|
||||
return;
|
||||
}
|
||||
if (shouldThrottle(taskId, moduleType, status)) {
|
||||
return;
|
||||
}
|
||||
taskProgressSnapshotMapper.update(null, new LambdaUpdateWrapper<TaskProgressSnapshotEntity>()
|
||||
.eq(TaskProgressSnapshotEntity::getId, existing.getId())
|
||||
.set(TaskProgressSnapshotEntity::getStatus, status)
|
||||
@@ -77,6 +90,31 @@ public class TaskProgressSnapshotService {
|
||||
.set(TaskProgressSnapshotEntity::getMessage, message)
|
||||
.set(TaskProgressSnapshotEntity::getSnapshotJson, snapshotJson)
|
||||
.set(TaskProgressSnapshotEntity::getUpdatedAt, now));
|
||||
touchLastWrite(taskId, moduleType, status);
|
||||
}
|
||||
|
||||
private boolean shouldThrottle(Long taskId, String moduleType, String status) {
|
||||
if (TERMINAL_STATUSES.contains(status)) {
|
||||
return false;
|
||||
}
|
||||
long interval = Math.max(0L, minUpdateIntervalMillis);
|
||||
if (interval <= 0L) {
|
||||
return false;
|
||||
}
|
||||
Long lastWrite = lastWriteAtMillis.get(cacheKey(taskId, moduleType));
|
||||
return lastWrite != null && System.currentTimeMillis() - lastWrite < interval;
|
||||
}
|
||||
|
||||
private void touchLastWrite(Long taskId, String moduleType, String status) {
|
||||
if (TERMINAL_STATUSES.contains(status)) {
|
||||
lastWriteAtMillis.remove(cacheKey(taskId, moduleType));
|
||||
return;
|
||||
}
|
||||
lastWriteAtMillis.put(cacheKey(taskId, moduleType), System.currentTimeMillis());
|
||||
}
|
||||
|
||||
private static String cacheKey(Long taskId, String moduleType) {
|
||||
return taskId + ":" + moduleType;
|
||||
}
|
||||
|
||||
private boolean isUnchanged(TaskProgressSnapshotEntity existing,
|
||||
@@ -116,6 +154,7 @@ public class TaskProgressSnapshotService {
|
||||
taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
|
||||
.eq(TaskProgressSnapshotEntity::getTaskId, taskId)
|
||||
.eq(TaskProgressSnapshotEntity::getModuleType, moduleType));
|
||||
lastWriteAtMillis.remove(cacheKey(taskId, moduleType));
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
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.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
|
||||
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.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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 63:为前端/后端进度快照增加写入去重和最小更新间隔。
|
||||
* TaskProgressSnapshotService 对进度快照写入做节流:内容未变化时跳过写库
|
||||
* (已有去重语义保留);内容变化但距上次写入不足最小更新间隔(默认 500ms)
|
||||
* 时跳过中间态写入,终态(SUCCESS/FAILED/CANCELLED)不受节流保证落库;
|
||||
* 删除快照时同步清理节流跟踪,不残留内存状态。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskProgressSnapshotServiceThrottleTest {
|
||||
|
||||
@BeforeAll
|
||||
static void initializeMybatisMetadata() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
TaskProgressSnapshotEntity.class);
|
||||
}
|
||||
|
||||
@Mock private TaskProgressSnapshotMapper taskProgressSnapshotMapper;
|
||||
@Mock private ObjectMapper objectMapper;
|
||||
|
||||
private TaskProgressSnapshotService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new TaskProgressSnapshotService(taskProgressSnapshotMapper, objectMapper);
|
||||
ReflectionTestUtils.setField(service, "minUpdateIntervalMillis", 1000L);
|
||||
}
|
||||
|
||||
private TaskProgressSnapshotEntity running() {
|
||||
TaskProgressSnapshotEntity entity = new TaskProgressSnapshotEntity();
|
||||
entity.setId(1L);
|
||||
entity.setTaskId(20581L);
|
||||
entity.setModuleType("SIMILAR_ASIN");
|
||||
entity.setStatus("RUNNING");
|
||||
entity.setTotalCount(10);
|
||||
entity.setSuccessCount(4);
|
||||
entity.setFailedCount(1);
|
||||
entity.setPendingCount(5);
|
||||
entity.setCurrentScopeKey("scope-1");
|
||||
entity.setMessage("processing");
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** 把某任务的节流跟踪时间拨到距今 millisAgo 毫秒前,模拟时间流逝。 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private void rewindLastWrite(Long taskId, String moduleType, long millisAgo) {
|
||||
Map<String, Long> tracking = (Map<String, Long>) ReflectionTestUtils
|
||||
.getField(service, "lastWriteAtMillis");
|
||||
tracking.put(taskId + ":" + moduleType, System.currentTimeMillis() - millisAgo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_normal_default_path() {
|
||||
// 正常路径:距上次写入超过最小间隔的变更落库,写入一次。
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
|
||||
rewindLastWrite(20581L, "SIMILAR_ASIN", 5_000L);
|
||||
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1,
|
||||
"scope-1", "processing", null);
|
||||
|
||||
verify(taskProgressSnapshotMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_normal_multiple_items() {
|
||||
// 批量场景:多个任务各自跟踪节流时间,互不影响,全部落库。
|
||||
for (long taskId = 1; taskId <= 3; taskId++) {
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(null);
|
||||
service.save(taskId, "MODULE_" + taskId, "RUNNING", 10, 0, 0,
|
||||
"scope-" + taskId, "start", null);
|
||||
}
|
||||
verify(taskProgressSnapshotMapper, times(3)).insert(any(TaskProgressSnapshotEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_normal_repeated_operation_is_idempotent() {
|
||||
// 幂等:相同内容重复保存不产生写库(内容去重),结果一致无副作用。
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
|
||||
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 4, 1,
|
||||
"scope-1", "processing", null);
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 4, 1,
|
||||
"scope-1", "processing", null);
|
||||
|
||||
verify(taskProgressSnapshotMapper, never()).update(any(), any());
|
||||
verify(taskProgressSnapshotMapper, never()).insert(any(TaskProgressSnapshotEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_boundary_empty_input() {
|
||||
// 空输入:null/空模块、空状态安全跳过,不发起任何写库调用。
|
||||
service.save(null, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, null, null, null);
|
||||
service.save(20581L, "", "RUNNING", 10, 0, 0, null, null, null);
|
||||
service.save(20581L, "SIMILAR_ASIN", " ", 10, 0, 0, null, null, null);
|
||||
|
||||
verify(taskProgressSnapshotMapper, never()).insert(any(TaskProgressSnapshotEntity.class));
|
||||
verify(taskProgressSnapshotMapper, never()).update(any(), any());
|
||||
verify(taskProgressSnapshotMapper, never()).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_boundary_single_item() {
|
||||
// 单元素:首次写入落库;间隔内变更被节流跳过;间隔过后变更落库。
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, "s1", "go", null);
|
||||
verify(taskProgressSnapshotMapper, times(1)).insert(any(TaskProgressSnapshotEntity.class));
|
||||
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1, "s2", "half", null);
|
||||
verify(taskProgressSnapshotMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
|
||||
rewindLastWrite(20581L, "SIMILAR_ASIN", 5_000L);
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 6, 1, "s3", "more", null);
|
||||
verify(taskProgressSnapshotMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_boundary_limit_and_overflow() {
|
||||
// 上限/超限:最小更新间隔设为 0(超限值)→ 节流禁用,
|
||||
// 每次变更都落库,不发生无界内存增长(跟踪 map 仅随任务数增长)。
|
||||
ReflectionTestUtils.setField(service, "minUpdateIntervalMillis", 0L);
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
|
||||
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1, "s1", "m1", null);
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 6, 1, "s1", "m2", null);
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 7, 1, "s1", "m3", null);
|
||||
|
||||
verify(taskProgressSnapshotMapper, times(3)).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_invalid_input_rejected() {
|
||||
// 非法参数:非正任务 id / 空白状态拒绝写入,不创建无效快照。
|
||||
service.save(0L, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, null, null, null);
|
||||
service.save(-5L, "SIMILAR_ASIN", "RUNNING", 10, 0, 0, null, null, null);
|
||||
|
||||
verify(taskProgressSnapshotMapper, never()).insert(any(TaskProgressSnapshotEntity.class));
|
||||
verify(taskProgressSnapshotMapper, never()).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_063_progress_frontend_dependency_failure_releases_resources() {
|
||||
// 依赖失败:唯一键冲突回落查询后正常更新(并发安全);
|
||||
// 写库异常传播不残留;删除快照后节流跟踪释放,间隔内再次写入不再被节流。
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(null)
|
||||
.thenReturn(running());
|
||||
doThrow(new DuplicateKeyException("dup")).when(taskProgressSnapshotMapper)
|
||||
.insert(any(TaskProgressSnapshotEntity.class));
|
||||
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 5, 1, "s1", "m1", null);
|
||||
verify(taskProgressSnapshotMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
|
||||
rewindLastWrite(20581L, "SIMILAR_ASIN", 5_000L);
|
||||
doThrow(new RuntimeException("db down")).when(taskProgressSnapshotMapper)
|
||||
.update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
assertThrows(RuntimeException.class, () -> service.save(20581L, "SIMILAR_ASIN",
|
||||
"RUNNING", 10, 6, 1, "s2", "m2", null), "写库异常传播");
|
||||
|
||||
service.delete(20581L, "SIMILAR_ASIN");
|
||||
verify(taskProgressSnapshotMapper, times(1))
|
||||
.delete(any(LambdaQueryWrapper.class));
|
||||
|
||||
when(taskProgressSnapshotMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(running());
|
||||
when(taskProgressSnapshotMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
|
||||
service.save(20581L, "SIMILAR_ASIN", "RUNNING", 10, 7, 1, "s3", "m3", null);
|
||||
verify(taskProgressSnapshotMapper, times(3)).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user