task-76: JSON owner 查询迁移到显式列并补充任务/状态复合索引
This commit is contained in:
+260
@@ -0,0 +1,260 @@
|
||||
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.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
|
||||
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
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 76:将 JSON owner 查询迁移到显式列并补充任务/状态复合索引。
|
||||
* biz_task_file_job 新增 owner 列(enqueue 时从 scope_key 提取、V96 迁移
|
||||
* 回填存量行),owner 任务查询从 LIKE '%:owner:%' 改为 owner = ? 等值查询,
|
||||
* 新增 (module_type, owner, status, retry_count) 复合索引;无 owner 的通用
|
||||
* 任务改为 owner IS NULL 过滤,语义与迁移前一致。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskFileJobOwnerColumnTest {
|
||||
|
||||
@Mock private TaskFileJobMapper taskFileJobMapper;
|
||||
@Mock private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
private TaskFileJobService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
TaskFileJobEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity ownerJob(Long id, String owner) {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setId(id);
|
||||
job.setTaskId(1000L + id);
|
||||
job.setModuleType("SIMILAR_ASIN");
|
||||
job.setScopeKey("task:" + id + ":owner:" + owner);
|
||||
job.setOwner(owner);
|
||||
job.setJobType("ASSEMBLE_RESULT");
|
||||
job.setStatus("PENDING");
|
||||
job.setRetryCount(0);
|
||||
job.setUpdatedAt(LocalDateTime.now());
|
||||
return job;
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity runningOwnerJob(Long id, String owner) {
|
||||
TaskFileJobEntity job = ownerJob(id, owner);
|
||||
job.setStatus("RUNNING");
|
||||
return job;
|
||||
}
|
||||
|
||||
private void stubOwnerQueries(List<TaskFileJobEntity> ownerJobs, List<TaskFileJobEntity> genericJobs) {
|
||||
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(ownerJobs, genericJobs);
|
||||
}
|
||||
|
||||
private void stubClaimResults(int... results) {
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class)))
|
||||
.thenReturn(results[0], java.util.Arrays.stream(results, 1, results.length).boxed().toArray(Integer[]::new));
|
||||
}
|
||||
|
||||
private void stubClaimedRows(List<TaskFileJobEntity> claimed) {
|
||||
when(taskFileJobMapper.selectById(any())).thenAnswer(invocation -> {
|
||||
Long id = invocation.getArgument(0);
|
||||
return claimed.stream().filter(job -> job.getId().equals(id)).findFirst().orElse(null);
|
||||
});
|
||||
}
|
||||
|
||||
private TaskFileJobEntity capturedInsert() {
|
||||
ArgumentCaptor<TaskFileJobEntity> captor = ArgumentCaptor.forClass(TaskFileJobEntity.class);
|
||||
verify(taskFileJobMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_normal_default_path() {
|
||||
// 默认路径:enqueue 时从 scope_key 提取 owner 落显式列;
|
||||
// owner 查询使用 owner = ? 等值条件,不再使用 LIKE。
|
||||
TaskFileJobEntity enqueued = service.enqueueAssembleResult(1L, "SIMILAR_ASIN", 2L, "task:1:owner:instance-a");
|
||||
assertEquals("instance-a", enqueued.getOwner(), "enqueue 时提取 owner");
|
||||
assertEquals("task:1:owner:instance-a", enqueued.getScopeKey(), "scope_key 原样保留");
|
||||
assertEquals("instance-a", capturedInsert().getOwner());
|
||||
|
||||
List<TaskFileJobEntity> ownerJobs = List.of(ownerJob(1L, "instance-a"));
|
||||
stubOwnerQueries(ownerJobs, List.of());
|
||||
stubClaimResults(1);
|
||||
stubClaimedRows(List.of(runningOwnerJob(1L, "instance-a")));
|
||||
|
||||
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, "instance-a");
|
||||
assertEquals(1, claimed.size(), "owner 任务按等值查询返回");
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(taskFileJobMapper, times(2)).selectList(captor.capture());
|
||||
String ownerSegment = captor.getAllValues().get(0).getCustomSqlSegment();
|
||||
assertTrue(ownerSegment.contains("owner = "), "owner 等值查询: " + ownerSegment);
|
||||
assertFalse(ownerSegment.contains("LIKE"), "不再使用 LIKE 模糊匹配: " + ownerSegment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_normal_multiple_items() {
|
||||
// 批量场景:多个 owner 的任务,按 owner 精确过滤只返回归属任务,
|
||||
// 通用任务补足仍保留(owner IS NULL),顺序稳定不丢失。
|
||||
List<TaskFileJobEntity> ownerJobs = List.of(ownerJob(1L, "instance-a"), ownerJob(2L, "instance-a"));
|
||||
stubOwnerQueries(ownerJobs, List.of(ownerJob(9L, null)));
|
||||
stubClaimResults(1, 1, 1);
|
||||
stubClaimedRows(List.of(runningOwnerJob(1L, "instance-a"), runningOwnerJob(2L, "instance-a"), runningOwnerJob(9L, null)));
|
||||
|
||||
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, "instance-a");
|
||||
|
||||
assertEquals(List.of(1L, 2L, 9L), claimed.stream().map(TaskFileJobEntity::getId).toList(),
|
||||
"owner 任务在前、通用任务补足,顺序稳定");
|
||||
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(taskFileJobMapper, times(2)).selectList(captor.capture());
|
||||
assertTrue(captor.getAllValues().get(0).getCustomSqlSegment().contains("owner = "), "owner 精确过滤");
|
||||
assertTrue(captor.getAllValues().get(1).getCustomSqlSegment().contains("owner IS NULL"),
|
||||
"通用任务按 owner IS NULL 过滤");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_normal_repeated_operation_is_idempotent() {
|
||||
// 幂等:同一 (taskId, moduleType, resultId) 重复 enqueue 不重复插入,
|
||||
// 复用既有 job;FAILED 重置路径同步刷新 owner,不产生重复记录。
|
||||
TaskFileJobEntity existing = ownerJob(5L, "instance-a");
|
||||
existing.setStatus("FAILED");
|
||||
existing.setRetryCount(2);
|
||||
when(taskFileJobMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing);
|
||||
when(taskFileJobMapper.selectById(5L)).thenReturn(existing);
|
||||
|
||||
TaskFileJobEntity result = service.enqueueAssembleResult(5L, "SIMILAR_ASIN", 50L, "task:5:owner:instance-a");
|
||||
|
||||
assertEquals(5L, result.getId(), "复用既有 job");
|
||||
verify(taskFileJobMapper, never()).insert(any(TaskFileJobEntity.class));
|
||||
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> updateCaptor =
|
||||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
verify(taskFileJobMapper, times(1)).update(isNull(), updateCaptor.capture());
|
||||
assertTrue(updateCaptor.getValue().getSqlSet().contains("owner"), "重置路径同步刷新 owner 列");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_boundary_empty_input() {
|
||||
// 空输入:scope_key 为空时 owner 为空;空白 owner 走通用 claim 路径
|
||||
// (不发起 owner 等值查询)。
|
||||
TaskFileJobEntity enqueued = service.enqueueAssembleResult(1L, "SIMILAR_ASIN", 2L, " ");
|
||||
assertNull(enqueued.getOwner(), "空 scope_key 不提取 owner");
|
||||
assertNull(capturedInsert().getOwner());
|
||||
|
||||
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, " ");
|
||||
|
||||
assertTrue(claimed.isEmpty());
|
||||
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(taskFileJobMapper, times(1)).selectList(captor.capture());
|
||||
assertFalse(captor.getValue().getCustomSqlSegment().contains("owner"), "空白 owner 不发起 owner 查询");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_boundary_single_item() {
|
||||
// 单元素:单任务单 owner 直接返回,不依赖批量路径。
|
||||
stubOwnerQueries(List.of(ownerJob(9L, "instance-a")), List.of());
|
||||
stubClaimResults(1);
|
||||
stubClaimedRows(List.of(runningOwnerJob(9L, "instance-a")));
|
||||
|
||||
List<TaskFileJobEntity> claimed = service.claimRunnableJobsForOwner(10, "instance-a");
|
||||
|
||||
assertEquals(List.of(9L), claimed.stream().map(TaskFileJobEntity::getId).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_boundary_limit_and_overflow() {
|
||||
// 上限/超限:limit 非法(0/负/超上限)按现有 clamp 回退,不发生无界取数。
|
||||
stubOwnerQueries(List.of(), List.of());
|
||||
|
||||
service.claimRunnableJobsForOwner(0, "instance-a");
|
||||
service.claimRunnableJobsForOwner(200, "instance-a");
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(taskFileJobMapper, times(4)).selectList(captor.capture());
|
||||
assertTrue(captor.getAllValues().get(0).getCustomSqlSegment().contains("limit 1"), "limit=0 回退到 1");
|
||||
assertTrue(captor.getAllValues().get(2).getCustomSqlSegment().contains("limit 100"), "limit=200 钳制到 100");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_invalid_input_rejected() {
|
||||
// 非法参数:必填字段缺失时 enqueue 拒绝返回 null 不落库;
|
||||
// scope_key 不含 owner 标记时 owner 为空但仍可入队。
|
||||
assertNull(service.enqueueAssembleResult(null, "SIMILAR_ASIN", 2L, "task:1:owner:instance-a"),
|
||||
"null taskId 拒绝");
|
||||
assertNull(service.enqueueAssembleResult(1L, " ", 2L, "task:1:owner:instance-a"),
|
||||
"空白 moduleType 拒绝");
|
||||
verify(taskFileJobMapper, never()).insert(any(TaskFileJobEntity.class));
|
||||
|
||||
TaskFileJobEntity noMarker = service.enqueueAssembleResult(1L, "SIMILAR_ASIN", 2L, "task:1:plain");
|
||||
assertNull(noMarker.getOwner(), "无 owner 标记不提取 owner");
|
||||
assertEquals("task:1:plain", noMarker.getScopeKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_076_owner_dependency_failure_releases_resources() {
|
||||
// 依赖失败:insert 主键冲突(DuplicateKeyException)时回退到既有 FAILED job,
|
||||
// 走重置路径刷新 owner 并继续发布派发事件;owner 查询抛异常时传播且不产生 claim 翻转。
|
||||
TaskFileJobEntity existing = ownerJob(7L, "instance-a");
|
||||
existing.setStatus("FAILED");
|
||||
existing.setRetryCount(2);
|
||||
// 首次查询无既有 job → 尝试 insert 触发主键冲突(并发提交),
|
||||
// 冲突后重新查询命中既有 FAILED job,走重置路径刷新 owner。
|
||||
when(taskFileJobMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(null, existing);
|
||||
when(taskFileJobMapper.insert(any(TaskFileJobEntity.class)))
|
||||
.thenThrow(new DuplicateKeyException("duplicate"));
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
|
||||
when(taskFileJobMapper.selectById(7L)).thenReturn(existing);
|
||||
|
||||
TaskFileJobEntity recovered = service.enqueueAssembleResult(7L, "SIMILAR_ASIN", 70L, "task:7:owner:instance-a");
|
||||
|
||||
assertEquals(7L, recovered.getId(), "冲突后回退到既有 job");
|
||||
verify(applicationEventPublisher, times(1)).publishEvent(any(TaskFileJobDispatchEvent.class));
|
||||
|
||||
when(taskFileJobMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenThrow(new RuntimeException("db down"));
|
||||
assertThrows(RuntimeException.class, () -> service.claimRunnableJobsForOwner(10, "instance-a"));
|
||||
// 唯一的 update 来自 enqueue 重置路径;claim 在查询阶段中止,未发起任何原子翻转。
|
||||
verify(taskFileJobMapper, times(1)).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user