task-36
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||
|
||||
/**
|
||||
* Task 36:每日累计文件归档提交阶段的版本 CAS 冲突信号。
|
||||
* 由提交阶段捕获并触发整次归档重试(释放店铺级锁后按最新状态重新组装),
|
||||
* 区别于需要调用方直接失败的运行时异常。
|
||||
*/
|
||||
class DailyStateConflictException extends RuntimeException {
|
||||
|
||||
DailyStateConflictException() {
|
||||
super("daily file state conflict");
|
||||
}
|
||||
}
|
||||
+66
-33
@@ -83,6 +83,8 @@ public class ShopDataCrawlTaskService {
|
||||
private static final int RESULT_SUCCESS = 1;
|
||||
/** 批量 IN 查询单批上限。 */
|
||||
private static final int ID_BATCH_SIZE = 500;
|
||||
/** Task 36:每日累计归档版本 CAS 冲突的最大重试次数(每次重试都释放店铺级锁)。 */
|
||||
private static final int MAX_DAILY_AGGREGATION_ATTEMPTS = 3;
|
||||
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
|
||||
private static final String PARTIAL_RESULT_MESSAGE = "Python 中断,已保留已回传的部分数据";
|
||||
private static final String RESULT_CHUNK_SCOPE_PREFIX = "result-chunks:";
|
||||
@@ -1742,46 +1744,73 @@ public class ShopDataCrawlTaskService {
|
||||
if (userId == null || shopKeyHash == null) {
|
||||
throw new BusinessException("店铺累计文件归属信息不完整");
|
||||
}
|
||||
TaskDistributedLockService.LockHandle lock = dailyFileService.acquireLock(userId, shopKey);
|
||||
if (lock == null) {
|
||||
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
||||
}
|
||||
// Task 36:版本号/CAS 短临界区。店铺级锁只覆盖“准备/提交”两个毫秒级短事务
|
||||
// (各自独立取锁/释放),整表 Excel 组装与 OSS 上传在两次取锁之间于锁外执行;
|
||||
// 提交阶段按 daily_file.version CAS,冲突时释放锁、清理本次上传对象并按最新状态重试(最多 3 次)。
|
||||
DailyAggregationResult persistedResult = null;
|
||||
try {
|
||||
DailyAggregationPreparation preparation = executeShortTransaction(
|
||||
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row));
|
||||
DailyWorkbookArtifact artifact = null;
|
||||
for (int attempt = 1; attempt <= MAX_DAILY_AGGREGATION_ATTEMPTS; attempt++) {
|
||||
DailyAggregationPreparation preparation;
|
||||
TaskDistributedLockService.LockHandle prepareLock = dailyFileService.acquireLock(userId, shopKey);
|
||||
if (prepareLock == null) {
|
||||
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
||||
}
|
||||
try {
|
||||
preparation = executeShortTransaction(
|
||||
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row));
|
||||
} finally {
|
||||
prepareLock.close();
|
||||
}
|
||||
if (preparation.alreadyArchived()) {
|
||||
return new DailyAggregationResult(List.of(), false);
|
||||
}
|
||||
|
||||
int addedRowCount = excelAssemblyService.countRows(List.of(snapshot));
|
||||
ShopDataCrawlDailyFileEntity baseDailyFile = resolveBaseDailyFile(
|
||||
ShopDataCrawlDailyFileEntity baseForAttempt = resolveBaseDailyFile(
|
||||
preparation.dailyFile(), userId, shopKeyHash, businessDate);
|
||||
DailyWorkbookArtifact artifact = assembleDailyWorkbook(
|
||||
task, snapshot, baseDailyFile, addedRowCount);
|
||||
try {
|
||||
persistedResult = executeShortTransaction(() -> persistDailyAggregation(
|
||||
task, row, userId, shopKey, shopKeyHash, businessDate,
|
||||
preparation, artifact, snapshot));
|
||||
if (persistedResult.discardUploadedObject() && artifact.uploaded()) {
|
||||
deleteObjectQuietly(artifact.objectKey());
|
||||
}
|
||||
return persistedResult;
|
||||
} catch (RuntimeException ex) {
|
||||
if (artifact.uploaded()) {
|
||||
deleteObjectQuietly(artifact.objectKey());
|
||||
}
|
||||
throw ex;
|
||||
// 组装在锁外执行。每次尝试都用本次准备阶段读到的最新 base 组装
|
||||
// (冲突重试时 base 已变化,复用过期的组装结果会把并发写入的行丢在对象外);
|
||||
// 零行引用对象不重复上传。
|
||||
if (artifact == null || !artifact.uploaded()) {
|
||||
artifact = assembleDailyWorkbook(task, snapshot, baseForAttempt,
|
||||
excelAssemblyService.countRows(List.of(snapshot)));
|
||||
}
|
||||
TaskDistributedLockService.LockHandle commitLock = dailyFileService.acquireLock(userId, shopKey);
|
||||
if (commitLock == null) {
|
||||
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
lock.close();
|
||||
} finally {
|
||||
if (persistedResult != null) {
|
||||
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
|
||||
DailyAggregationPreparation preparationForAttempt = preparation;
|
||||
DailyWorkbookArtifact artifactForAttempt = artifact;
|
||||
try {
|
||||
persistedResult = executeShortTransaction(() -> persistDailyAggregation(
|
||||
task, row, userId, shopKey, shopKeyHash, businessDate,
|
||||
preparationForAttempt, artifactForAttempt, snapshot,
|
||||
preparationForAttempt.dailyFile()));
|
||||
if (persistedResult.discardUploadedObject() && artifactForAttempt.uploaded()) {
|
||||
deleteObjectQuietly(artifactForAttempt.objectKey());
|
||||
} else {
|
||||
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
|
||||
}
|
||||
return persistedResult;
|
||||
} catch (DailyStateConflictException conflictEx) {
|
||||
// 冲突:本次上传对象作废并释放,按最新状态重新组装再试。
|
||||
if (artifact.uploaded()) {
|
||||
deleteObjectQuietly(artifact.objectKey());
|
||||
artifact = null;
|
||||
}
|
||||
if (attempt >= MAX_DAILY_AGGREGATION_ATTEMPTS) {
|
||||
throw new BusinessException("店铺累计文件并发更新冲突,请稍后重试");
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
if (artifact != null && artifact.uploaded()) {
|
||||
deleteObjectQuietly(artifact.objectKey());
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
} finally {
|
||||
commitLock.close();
|
||||
}
|
||||
}
|
||||
throw new BusinessException("店铺累计文件并发更新冲突,请稍后重试");
|
||||
}
|
||||
|
||||
private DailyAggregationPreparation prepareDailyAggregation(Long userId,
|
||||
@@ -1937,14 +1966,18 @@ public class ShopDataCrawlTaskService {
|
||||
LocalDate businessDate,
|
||||
DailyAggregationPreparation preparation,
|
||||
DailyWorkbookArtifact artifact,
|
||||
ShopDataCrawlResultItemVo snapshot) {
|
||||
ShopDataCrawlResultItemVo snapshot,
|
||||
ShopDataCrawlDailyFileEntity expectedBase) {
|
||||
ShopDataCrawlDailyFileEntity dailyFile = dailyFileService.findForUpdate(
|
||||
userId, shopKeyHash, businessDate);
|
||||
if (handleExistingDailyMembership(row, dailyFile)) {
|
||||
return new DailyAggregationResult(List.of(), true);
|
||||
}
|
||||
if (!sameDailyFileState(preparation.dailyFile(), dailyFile)) {
|
||||
throw new BusinessException("当天累计文件状态已变化,请重试文件任务");
|
||||
if (!Objects.equals(dailyFile == null ? null : dailyFile.getId(),
|
||||
expectedBase == null ? null : expectedBase.getId())
|
||||
|| !Objects.equals(dailyFile == null ? null : dailyFile.getVersion(),
|
||||
expectedBase == null ? null : expectedBase.getVersion())) {
|
||||
throw new DailyStateConflictException();
|
||||
}
|
||||
|
||||
List<ShopDataCrawlDailyFileEntity> olderFiles = dailyFileService.findOlder(
|
||||
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
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.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
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.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
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 36:为每日累计文件引入版本号/CAS,缩短店铺级锁的持有时间。
|
||||
* 原实现持有店铺级锁覆盖“准备+组装+提交”全流程(含整表 Excel 组装与 OSS 上传,
|
||||
* 耗时最长);重构为两个短临界区(准备 / 提交),组装在锁外执行,
|
||||
* 提交阶段按 daily_file.version CAS,冲突时释放锁重试(最多 3 次),
|
||||
* 从而把锁的持有时间从“秒级组装”缩短到“毫秒级两个短事务”。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ShopDataCrawlDailyFileLockTest {
|
||||
|
||||
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||
private static final String SHOP_NAME = "shop-a";
|
||||
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 29);
|
||||
private static final LocalDateTime BUSINESS_TIME = LocalDateTime.of(2026, 8, 29, 12, 0);
|
||||
|
||||
@BeforeAll
|
||||
static void initializeMybatisMetadata() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyFileEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyMemberEntity.class);
|
||||
}
|
||||
|
||||
@Mock private FileTaskMapper fileTaskMapper;
|
||||
@Mock private FileResultMapper fileResultMapper;
|
||||
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||
@Mock private OssStorageService ossStorageService;
|
||||
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
@Mock private TaskPressureProperties taskPressureProperties;
|
||||
@Mock private TaskFileJobService taskFileJobService;
|
||||
@Mock private TaskResultItemService taskResultItemService;
|
||||
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
@Mock private InstanceMetadata instanceMetadata;
|
||||
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
private ShopDataCrawlTaskService service;
|
||||
|
||||
/** 内存中的结果行表(listTaskRows 读取源 + updateById 回写目标)。 */
|
||||
private final List<FileResultEntity> dbResultRows = new ArrayList<>();
|
||||
/** 内存中的 daily_file 表。 */
|
||||
private final List<ShopDataCrawlDailyFileEntity> dbDailyFiles = new ArrayList<>();
|
||||
/** 内存中的 daily_member 表。 */
|
||||
private final List<ShopDataCrawlDailyMemberEntity> dbMembers = new ArrayList<>();
|
||||
private final AtomicLong memberIdSeq = new AtomicLong(1000);
|
||||
private final AtomicLong fileIdSeq = new AtomicLong(5000);
|
||||
private long nextResultId = 7200;
|
||||
private Long lastJobTaskId;
|
||||
private String lastUploadedObjectKey;
|
||||
/** 店铺级锁获取次数(验证锁持有时间缩短:两次短临界区各取一次)。 */
|
||||
private final AtomicInteger lockAcquireCount = new AtomicInteger();
|
||||
private final AtomicReference<TaskDistributedLockService.LockHandle> lastLock = new AtomicReference<>();
|
||||
/** 本次测试获取到的全部店铺级锁句柄(验证每个临界区取到的锁都被释放)。 */
|
||||
private final List<TaskDistributedLockService.LockHandle> acquiredLocks = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void configureStorage() {
|
||||
service = new ShopDataCrawlTaskService(
|
||||
fileTaskMapper,
|
||||
fileResultMapper,
|
||||
shopDataCrawlResolveService,
|
||||
excelAssemblyService,
|
||||
taskCacheService,
|
||||
ossStorageService,
|
||||
ziniaoShopSwitchService,
|
||||
objectMapper,
|
||||
taskPressureProperties,
|
||||
taskFileJobService,
|
||||
taskResultItemService,
|
||||
taskProgressSnapshotService,
|
||||
taskDistributedLockService,
|
||||
taskChunkMapper,
|
||||
taskScopeStateMapper,
|
||||
transientPayloadStorageService,
|
||||
instanceMetadata,
|
||||
dailyFileService,
|
||||
null);
|
||||
|
||||
dbResultRows.clear();
|
||||
dbDailyFiles.clear();
|
||||
dbMembers.clear();
|
||||
memberIdSeq.set(1000);
|
||||
fileIdSeq.set(5000);
|
||||
nextResultId = 7200;
|
||||
lastJobTaskId = null;
|
||||
lastUploadedObjectKey = null;
|
||||
lockAcquireCount.set(0);
|
||||
lastLock.set(null);
|
||||
acquiredLocks.clear();
|
||||
|
||||
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
|
||||
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE))).thenReturn(0L);
|
||||
lenient().doNothing().when(taskResultItemService)
|
||||
.replaceTaskSnapshots(anyLong(), eq(MODULE_TYPE), any(), any());
|
||||
lenient().doNothing().when(taskProgressSnapshotService)
|
||||
.save(anyLong(), any(), any(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||
String value = invocation.getArgument(0);
|
||||
return value == null ? "" : value.trim();
|
||||
});
|
||||
lenient().when(fileTaskMapper.selectById(anyLong())).thenReturn(null);
|
||||
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(1);
|
||||
lenient().when(fileResultMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||
lenient().when(fileResultMapper.selectCount(any())).thenReturn(0L);
|
||||
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenAnswer(invocation -> {
|
||||
String key = "oss/lock/" + System.nanoTime() + ".xlsx";
|
||||
lastUploadedObjectKey = key;
|
||||
return key;
|
||||
});
|
||||
lenient().when(ossStorageService.readObjectBytes(anyString())).thenReturn(new byte[0]);
|
||||
lenient().doNothing().when(ossStorageService).deleteObject(anyString());
|
||||
lenient().when(excelAssemblyService.countRows(any())).thenAnswer(invocation -> {
|
||||
List<?> items = invocation.getArgument(0);
|
||||
return items == null ? 0 : items.size();
|
||||
});
|
||||
lenient().doAnswer(invocation -> {
|
||||
List<?> items = invocation.getArgument(1);
|
||||
return items == null ? 0 : items.size();
|
||||
}).when(excelAssemblyService).writeWorkbook(any(), any());
|
||||
|
||||
// 店铺级锁:每次获取返回独立句柄并计数(两次短临界区各取一次)。
|
||||
lenient().when(dailyFileService.acquireLock(anyLong(), anyString())).thenAnswer(invocation -> {
|
||||
TaskDistributedLockService.LockHandle handle = mock(TaskDistributedLockService.LockHandle.class);
|
||||
lockAcquireCount.incrementAndGet();
|
||||
lastLock.set(handle);
|
||||
acquiredLocks.add(handle);
|
||||
return handle;
|
||||
});
|
||||
lenient().when(dailyFileService.currentBusinessDate()).thenReturn(BUSINESS_DATE);
|
||||
lenient().when(dailyFileService.currentBusinessDateTime()).thenReturn(BUSINESS_TIME);
|
||||
lenient().when(dailyFileService.shopKeyHash(anyString())).thenAnswer(invocation -> {
|
||||
String key = invocation.getArgument(0);
|
||||
return key == null ? null : "hash:" + key;
|
||||
});
|
||||
lenient().when(dailyFileService.shopKey(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||
FileResultEntity row = invocation.getArgument(0);
|
||||
return row == null ? null : row.getSourceFilename();
|
||||
});
|
||||
lenient().when(dailyFileService.findForUpdate(anyLong(), anyString(), any()))
|
||||
.thenAnswer(invocation -> copyDailyFile(findDailyFile(
|
||||
invocation.getArgument(0), invocation.getArgument(1))));
|
||||
lenient().when(dailyFileService.findOlder(anyLong(), anyString(), any())).thenReturn(List.of());
|
||||
lenient().when(dailyFileService.findByLatestResultId(anyLong())).thenReturn(List.of());
|
||||
lenient().when(dailyFileService.findById(anyLong())).thenReturn(null);
|
||||
lenient().when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
||||
lenient().doNothing().when(dailyFileService).deleteDailyFile(anyLong());
|
||||
lenient().doNothing().when(dailyFileService).reassignMembers(anyLong(), anyLong());
|
||||
lenient().doNothing().when(dailyFileService).deleteMembersForResults(any());
|
||||
captureDailyFilePersistence();
|
||||
captureMemberInserts();
|
||||
captureResultUpdates();
|
||||
|
||||
// 结果行读取:listTaskRows 按 taskId+moduleType 过滤并升序;其他查询返回空。
|
||||
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||
Wrapper<FileResultEntity> wrapper = invocation.getArgument(0);
|
||||
if (wrapper instanceof LambdaQueryWrapper<?> query
|
||||
&& query.getSqlSegment() != null && query.getSqlSegment().contains("taskId")) {
|
||||
List<FileResultEntity> rows = dbResultRows.stream()
|
||||
.filter(r -> Objects.equals(r.getTaskId(), lastJobTaskId))
|
||||
.sorted(Comparator.comparing(FileResultEntity::getId))
|
||||
.toList();
|
||||
return new ArrayList<>(rows);
|
||||
}
|
||||
return List.of();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_normal_default_path() {
|
||||
// 正常路径:归档成功,版本号从 1 起步;锁只取两次(准备/提交短临界区),
|
||||
// 整表组装在锁外完成;成员行与累计文件各一。
|
||||
FileResultEntity row = addResultRow(7201L, 1L, 1, SHOP_NAME, null);
|
||||
processJob(1L, List.of(row), snapshot(7201L));
|
||||
|
||||
assertEquals(1, dbMembers.size(), "默认路径产生一个成员行");
|
||||
assertEquals(1, dbDailyFiles.size(), "默认路径产生一个累计文件");
|
||||
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "版本号从 1 起步");
|
||||
assertEquals(2, lockAcquireCount.get(), "准备/提交两次短临界区各取一次锁");
|
||||
assertNotNull(lastUploadedObjectKey, "锁外组装上传了新对象");
|
||||
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_normal_multiple_items() {
|
||||
// 多结果批量场景:多个结果先后归档,版本号随每次提交递增,成员累积且顺序稳定。
|
||||
processJob(1L, List.of(addResultRow(7202L, 1L, 1, SHOP_NAME, null)), snapshot(7202L));
|
||||
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "首次归档版本 1");
|
||||
processJob(2L, List.of(addResultRow(7203L, 2L, 1, SHOP_NAME, null)), snapshot(7203L));
|
||||
|
||||
assertEquals(2, dbMembers.size(), "两个结果各一个成员行");
|
||||
assertEquals(2L, dbDailyFiles.get(0).getVersion(), "第二次归档版本递增到 2");
|
||||
List<ShopDataCrawlDailyMemberEntity> members = dbMembers.stream()
|
||||
.sorted(Comparator.comparing(ShopDataCrawlDailyMemberEntity::getResultId))
|
||||
.toList();
|
||||
assertEquals(7202L, members.get(0).getResultId());
|
||||
assertEquals(7203L, members.get(1).getResultId());
|
||||
assertNotNull(members.get(0).getRowPayload());
|
||||
assertNotNull(members.get(1).getRowPayload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_normal_repeated_operation_is_idempotent() {
|
||||
// 幂等:同一结果重复归档不产生第二个成员行、版本号不递增、不重复上传对象。
|
||||
FileResultEntity row = addResultRow(7204L, 1L, 1, SHOP_NAME, null);
|
||||
processJob(1L, List.of(row), snapshot(7204L));
|
||||
long versionAfterFirst = dbDailyFiles.get(0).getVersion();
|
||||
String objectAfterFirst = lastUploadedObjectKey;
|
||||
processJob(1L, List.of(row), snapshot(7204L));
|
||||
|
||||
assertEquals(1, dbMembers.size(), "重复归档不产生重复成员行");
|
||||
assertEquals(versionAfterFirst, dbDailyFiles.get(0).getVersion(), "重复归档版本号不递增");
|
||||
assertEquals(objectAfterFirst, lastUploadedObjectKey, "重复归档复用既有对象,不重复上传");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_boundary_empty_input() {
|
||||
// 空输入:没有成功结果时安全跳过,不取店铺级锁、不创建成员/累计文件、不上传对象。
|
||||
FileResultEntity row = addResultRow(7205L, 1L, 0, SHOP_NAME, null);
|
||||
processJob(1L, List.of(row), snapshot(7205L));
|
||||
|
||||
assertTrue(dbMembers.isEmpty(), "无成功结果不创建成员行");
|
||||
assertTrue(dbDailyFiles.isEmpty(), "无成功结果不创建累计文件");
|
||||
assertEquals(0, lockAcquireCount.get(), "无成功结果不获取店铺级锁");
|
||||
verify(ossStorageService, never()).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_boundary_single_item() {
|
||||
// 单元素:单结果归档同样只取两次锁(短临界区),版本号 1,结果正确。
|
||||
FileResultEntity row = addResultRow(7206L, 1L, 1, SHOP_NAME, null);
|
||||
processJob(1L, List.of(row), snapshot(7206L));
|
||||
|
||||
assertEquals(1, dbMembers.size());
|
||||
assertEquals(1, dbDailyFiles.size());
|
||||
assertEquals(1L, dbDailyFiles.get(0).getVersion());
|
||||
assertEquals(2, lockAcquireCount.get(), "单结果也是两次短临界区取锁");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_boundary_limit_and_overflow() {
|
||||
// 上限/超限:大量结果逐个归档,版本号与成员数一致递增,无重复无丢失,
|
||||
// 每次归档的锁获取次数仍为两次(不随量级放大锁持有时间)。
|
||||
int limit = 210;
|
||||
for (int i = 0; i < limit; i++) {
|
||||
long resultId = nextResultId++;
|
||||
long taskId = i + 1L;
|
||||
FileResultEntity row = addResultRow(resultId, taskId, 1, SHOP_NAME, null);
|
||||
processJob(taskId, List.of(row), snapshot(resultId));
|
||||
}
|
||||
|
||||
assertEquals(limit, dbMembers.size(), "大量结果成员行全部保留");
|
||||
assertEquals(Long.valueOf(limit), dbDailyFiles.get(0).getVersion(), "版本号与归档次数一致");
|
||||
assertEquals(limit * 2, lockAcquireCount.get(), "每次归档恰好两次短临界区取锁");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_invalid_input_rejected() {
|
||||
// 非法参数/CAS 冲突:并发提交导致 version CAS 持续冲突时,重试 3 次后
|
||||
// 抛出可识别的 BusinessException;每次冲突后上传对象被清理、锁被释放。
|
||||
// 通过 findForUpdate 在每次读取后把版本号写回内存表(并返回读到的快照副本)
|
||||
// 模拟并发写入:提交阶段读到的版本总是比准备阶段新 → 每次 CAS 都冲突。
|
||||
// doAnswer().when() 覆盖 @BeforeEach 中同参数 when() 注册(后者重注册不生效)。
|
||||
seedDailyFile();
|
||||
FileResultEntity row = addResultRow(7207L, 1L, 1, SHOP_NAME, null);
|
||||
lenient().doAnswer(invocation -> {
|
||||
ShopDataCrawlDailyFileEntity current = findDailyFile(invocation.getArgument(0), invocation.getArgument(1));
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
ShopDataCrawlDailyFileEntity readCopy = copyDailyFile(current);
|
||||
current.setVersion(readCopy.getVersion() + 1L);
|
||||
return readCopy;
|
||||
}).when(dailyFileService).findForUpdate(anyLong(), anyString(), any());
|
||||
|
||||
Exception ex = assertThrows(BusinessException.class,
|
||||
() -> processJob(1L, List.of(row), snapshot(7207L)));
|
||||
assertTrue(ex.getMessage().contains("并发"), "CAS 冲突超限后错误消息可识别");
|
||||
assertTrue(dbMembers.isEmpty(), "冲突放弃后不残留成员行");
|
||||
assertEquals(1, dbDailyFiles.size(), "冲突放弃后既有累计文件不受破坏");
|
||||
assertEquals(6L, dbDailyFiles.get(0).getVersion(), "版本只随并发写入模拟推进(每次读取+1),冲突归档未提交");
|
||||
assertEquals(6, acquiredLocks.size(), "三次尝试各取准备/提交两次锁");
|
||||
for (TaskDistributedLockService.LockHandle handle : acquiredLocks) {
|
||||
verify(handle).close();
|
||||
}
|
||||
verify(ossStorageService, times(3)).deleteObject(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_036_daily_file_lock_dependency_failure_releases_resources() {
|
||||
// 依赖失败:提交阶段 DB 写入失败时错误可恢复,锁已释放、上传对象已清理、
|
||||
// 不残留新成员/累计文件;组装成功路径不受影响(可再次归档)。
|
||||
seedDailyFile();
|
||||
doThrow(new RuntimeException("commit db down"))
|
||||
.when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||
FileResultEntity row = addResultRow(7208L, 1L, 1, SHOP_NAME, null);
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> processJob(1L, List.of(row), snapshot(7208L)));
|
||||
|
||||
assertTrue(dbMembers.isEmpty(), "提交失败不残留成员行");
|
||||
assertEquals(1, dbDailyFiles.size(), "既有累计文件未被破坏");
|
||||
assertEquals(0L, dbDailyFiles.get(0).getVersion(), "既有累计文件版本未被改动");
|
||||
assertNotNull(lastLock.get(), "提交阶段已获取锁");
|
||||
verify(lastLock.get()).close();
|
||||
verify(ossStorageService).deleteObject(anyString());
|
||||
|
||||
// 错误可恢复:恢复 DB 后同一结果重新归档成功。
|
||||
lenient().doAnswer(invocation -> {
|
||||
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||
for (int i = 0; i < dbDailyFiles.size(); i++) {
|
||||
ShopDataCrawlDailyFileEntity existing = dbDailyFiles.get(i);
|
||||
if (Objects.equals(existing.getUserId(), entity.getUserId())
|
||||
&& Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
|
||||
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
|
||||
entity.setId(existing.getId());
|
||||
dbDailyFiles.set(i, entity);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
entity.setId(fileIdSeq.incrementAndGet());
|
||||
dbDailyFiles.add(entity);
|
||||
return null;
|
||||
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||
processJob(1L, List.of(row), snapshot(7208L));
|
||||
assertEquals(1, dbMembers.size(), "恢复后同一结果重新归档成功");
|
||||
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "恢复后版本号递增");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void seedDailyFile() {
|
||||
ShopDataCrawlDailyFileEntity file = new ShopDataCrawlDailyFileEntity();
|
||||
file.setId(1L);
|
||||
file.setUserId(7L);
|
||||
file.setShopKeyHash("hash:" + SHOP_NAME);
|
||||
file.setShopKey(SHOP_NAME);
|
||||
file.setBusinessDate(BUSINESS_DATE);
|
||||
file.setResultFilename("daily.xlsx");
|
||||
file.setResultFileUrl("oss/daily/seed.xlsx");
|
||||
file.setResultFileSize(10L);
|
||||
file.setVersion(0L);
|
||||
file.setRowCount(0);
|
||||
file.setCreatedAt(BUSINESS_TIME);
|
||||
file.setUpdatedAt(BUSINESS_TIME);
|
||||
dbDailyFiles.add(file);
|
||||
}
|
||||
|
||||
private FileResultEntity addResultRow(long id, long taskId, int success, String shopName, String resultFileUrl) {
|
||||
FileResultEntity row = new FileResultEntity();
|
||||
row.setId(id);
|
||||
row.setTaskId(taskId);
|
||||
row.setModuleType(MODULE_TYPE);
|
||||
row.setSuccess(success);
|
||||
row.setSourceFilename(shopName);
|
||||
row.setSourceFileUrl("shop-id-" + id);
|
||||
row.setUserId(7L);
|
||||
row.setCreatedAt(LocalDateTime.now());
|
||||
row.setResultFileUrl(resultFileUrl);
|
||||
dbResultRows.add(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
private ShopDataCrawlResultItemVo snapshot(long resultId) {
|
||||
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||
item.setResultId(resultId);
|
||||
item.setTaskId(1L);
|
||||
item.setShopName(SHOP_NAME);
|
||||
item.setShopId("shop-id-" + resultId);
|
||||
item.setSuccess(true);
|
||||
item.setMatched(true);
|
||||
item.setTaskStatus("SUCCESS");
|
||||
item.setCountryCodes(List.of("DE"));
|
||||
return item;
|
||||
}
|
||||
|
||||
private void processJob(long jobTaskId, List<FileResultEntity> rows, ShopDataCrawlResultItemVo snapshot) {
|
||||
lastJobTaskId = jobTaskId;
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setId(jobTaskId);
|
||||
job.setTaskId(jobTaskId);
|
||||
job.setModuleType(MODULE_TYPE);
|
||||
FileTaskEntity task = taskEntity(jobTaskId);
|
||||
lenient().when(fileTaskMapper.selectById(jobTaskId)).thenReturn(task);
|
||||
lenient().when(taskResultItemService.listResultSnapshots(eq(jobTaskId), eq(MODULE_TYPE), any()))
|
||||
.thenReturn(snapshot == null ? List.of() : List.of(snapshot));
|
||||
service.processResultFileJob(job);
|
||||
}
|
||||
|
||||
private FileTaskEntity taskEntity(long taskId) {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(taskId);
|
||||
task.setModuleType(MODULE_TYPE);
|
||||
task.setStatus("RUNNING");
|
||||
task.setUserId(7L);
|
||||
task.setOwnerInstanceId("instance-a");
|
||||
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||
task.setResultJson("[]");
|
||||
task.setCreatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
return task;
|
||||
}
|
||||
|
||||
private void captureResultUpdates() {
|
||||
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||
FileResultEntity updated = invocation.getArgument(0);
|
||||
for (int i = 0; i < dbResultRows.size(); i++) {
|
||||
if (Objects.equals(dbResultRows.get(i).getId(), updated.getId())) {
|
||||
dbResultRows.set(i, updated);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
dbResultRows.add(updated);
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
private void captureMemberInserts() {
|
||||
lenient().when(dailyFileService.addMemberWithPayload(anyLong(), anyLong(), anyLong(), anyString()))
|
||||
.thenAnswer(invocation -> {
|
||||
long dailyFileId = invocation.getArgument(0);
|
||||
long taskId = invocation.getArgument(1);
|
||||
long resultId = invocation.getArgument(2);
|
||||
String rowPayload = invocation.getArgument(3);
|
||||
boolean duplicate = dbMembers.stream().anyMatch(m ->
|
||||
Objects.equals(m.getDailyFileId(), dailyFileId)
|
||||
&& Objects.equals(m.getResultId(), resultId));
|
||||
if (duplicate) {
|
||||
return false;
|
||||
}
|
||||
ShopDataCrawlDailyMemberEntity member = new ShopDataCrawlDailyMemberEntity();
|
||||
member.setId(memberIdSeq.incrementAndGet());
|
||||
member.setDailyFileId(dailyFileId);
|
||||
member.setTaskId(taskId);
|
||||
member.setResultId(resultId);
|
||||
member.setRowPayload(rowPayload);
|
||||
member.setCreatedAt(BUSINESS_TIME.plusMinutes(dbMembers.size()));
|
||||
dbMembers.add(member);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void captureDailyFilePersistence() {
|
||||
lenient().doAnswer(invocation -> {
|
||||
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||
entity.setId(fileIdSeq.incrementAndGet());
|
||||
dbDailyFiles.add(entity);
|
||||
return null;
|
||||
}).when(dailyFileService).insert(any(ShopDataCrawlDailyFileEntity.class));
|
||||
lenient().doAnswer(invocation -> {
|
||||
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||
for (int i = 0; i < dbDailyFiles.size(); i++) {
|
||||
ShopDataCrawlDailyFileEntity existing = dbDailyFiles.get(i);
|
||||
if (Objects.equals(existing.getUserId(), entity.getUserId())
|
||||
&& Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
|
||||
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
|
||||
entity.setId(existing.getId());
|
||||
dbDailyFiles.set(i, copyDailyFile(entity));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
entity.setId(fileIdSeq.incrementAndGet());
|
||||
dbDailyFiles.add(copyDailyFile(entity));
|
||||
return null;
|
||||
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||
lenient().when(dailyFileService.listMembers(anyLong())).thenAnswer(invocation -> {
|
||||
long dailyFileId = invocation.getArgument(0);
|
||||
return dbMembers.stream()
|
||||
.filter(m -> Objects.equals(m.getDailyFileId(), dailyFileId))
|
||||
.sorted(Comparator
|
||||
.comparing(ShopDataCrawlDailyMemberEntity::getCreatedAt,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(ShopDataCrawlDailyMemberEntity::getId,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.toList();
|
||||
});
|
||||
lenient().when(dailyFileService.containsResult(anyLong(), anyLong())).thenAnswer(invocation -> {
|
||||
long dailyFileId = invocation.getArgument(0);
|
||||
long resultId = invocation.getArgument(1);
|
||||
return dbMembers.stream().anyMatch(m ->
|
||||
Objects.equals(m.getDailyFileId(), dailyFileId) && Objects.equals(m.getResultId(), resultId));
|
||||
});
|
||||
lenient().when(dailyFileService.findMembersByResultId(anyLong())).thenAnswer(invocation -> {
|
||||
long resultId = invocation.getArgument(0);
|
||||
return dbMembers.stream().filter(m -> Objects.equals(m.getResultId(), resultId)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
|
||||
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
|
||||
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ShopDataCrawlDailyFileEntity copyDailyFile(ShopDataCrawlDailyFileEntity source) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
ShopDataCrawlDailyFileEntity copy = new ShopDataCrawlDailyFileEntity();
|
||||
copy.setId(source.getId());
|
||||
copy.setUserId(source.getUserId());
|
||||
copy.setShopKeyHash(source.getShopKeyHash());
|
||||
copy.setShopKey(source.getShopKey());
|
||||
copy.setBusinessDate(source.getBusinessDate());
|
||||
copy.setLatestTaskId(source.getLatestTaskId());
|
||||
copy.setLatestResultId(source.getLatestResultId());
|
||||
copy.setResultFilename(source.getResultFilename());
|
||||
copy.setResultFileUrl(source.getResultFileUrl());
|
||||
copy.setResultFileSize(source.getResultFileSize());
|
||||
copy.setResultContentType(source.getResultContentType());
|
||||
copy.setRowCount(source.getRowCount());
|
||||
copy.setVersion(source.getVersion());
|
||||
copy.setLastSuccessAt(source.getLastSuccessAt());
|
||||
copy.setCreatedAt(source.getCreatedAt());
|
||||
copy.setUpdatedAt(source.getUpdatedAt());
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user