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.mapper.TaskProgressSnapshotMapper;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
|
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class TaskProgressSnapshotService {
|
public class TaskProgressSnapshotService {
|
||||||
|
|
||||||
|
private static final Set<String> TERMINAL_STATUSES = Set.of("SUCCESS", "FAILED", "CANCELLED", "CANCELED");
|
||||||
|
|
||||||
private final TaskProgressSnapshotMapper taskProgressSnapshotMapper;
|
private final TaskProgressSnapshotMapper taskProgressSnapshotMapper;
|
||||||
private final ObjectMapper objectMapper;
|
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
|
@Transactional
|
||||||
public void save(Long taskId,
|
public void save(Long taskId,
|
||||||
@@ -54,6 +63,7 @@ public class TaskProgressSnapshotService {
|
|||||||
entity.setUpdatedAt(now);
|
entity.setUpdatedAt(now);
|
||||||
try {
|
try {
|
||||||
taskProgressSnapshotMapper.insert(entity);
|
taskProgressSnapshotMapper.insert(entity);
|
||||||
|
touchLastWrite(taskId, moduleType, status);
|
||||||
return;
|
return;
|
||||||
} catch (DuplicateKeyException ignored) {
|
} catch (DuplicateKeyException ignored) {
|
||||||
existing = find(taskId, moduleType);
|
existing = find(taskId, moduleType);
|
||||||
@@ -66,6 +76,9 @@ public class TaskProgressSnapshotService {
|
|||||||
currentScopeKey, message, snapshotJson)) {
|
currentScopeKey, message, snapshotJson)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (shouldThrottle(taskId, moduleType, status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
taskProgressSnapshotMapper.update(null, new LambdaUpdateWrapper<TaskProgressSnapshotEntity>()
|
taskProgressSnapshotMapper.update(null, new LambdaUpdateWrapper<TaskProgressSnapshotEntity>()
|
||||||
.eq(TaskProgressSnapshotEntity::getId, existing.getId())
|
.eq(TaskProgressSnapshotEntity::getId, existing.getId())
|
||||||
.set(TaskProgressSnapshotEntity::getStatus, status)
|
.set(TaskProgressSnapshotEntity::getStatus, status)
|
||||||
@@ -77,6 +90,31 @@ public class TaskProgressSnapshotService {
|
|||||||
.set(TaskProgressSnapshotEntity::getMessage, message)
|
.set(TaskProgressSnapshotEntity::getMessage, message)
|
||||||
.set(TaskProgressSnapshotEntity::getSnapshotJson, snapshotJson)
|
.set(TaskProgressSnapshotEntity::getSnapshotJson, snapshotJson)
|
||||||
.set(TaskProgressSnapshotEntity::getUpdatedAt, now));
|
.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,
|
private boolean isUnchanged(TaskProgressSnapshotEntity existing,
|
||||||
@@ -116,6 +154,7 @@ public class TaskProgressSnapshotService {
|
|||||||
taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
|
taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
|
||||||
.eq(TaskProgressSnapshotEntity::getTaskId, taskId)
|
.eq(TaskProgressSnapshotEntity::getTaskId, taskId)
|
||||||
.eq(TaskProgressSnapshotEntity::getModuleType, moduleType));
|
.eq(TaskProgressSnapshotEntity::getModuleType, moduleType));
|
||||||
|
lastWriteAtMillis.remove(cacheKey(taskId, moduleType));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String writeJson(Object value) {
|
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