task-54: finalRowCount 改为任务内增量计数(upsert newlyInserted 累计),移除每 chunk 全表 COUNT(*)

This commit is contained in:
2026-08-30 16:41:02 +08:00
parent e8cedbcb50
commit 91f760f789
3 changed files with 282 additions and 18 deletions
@@ -0,0 +1,253 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 54:将 finalRowCount 从每个 chunk COUNT(*) 改为任务内增量计数。
* upsertAccepted 返回的 newlyInserted 是本次调用真实新增的行数(仅
* existing == null 的真 INSERThash 相等跳过与存量更新均不计入),
* 调用方(CollectDataService)按任务内累加得到 finalRowCount,替代每
* chunk 一次全表 COUNT(*);乱序提交下累计值与顺序无关,重提幂等不重复
* 计数,批量失败扣除未写入的新行。
*/
class CollectDataResultItemCountTest {
private TaskResultItemMapper taskResultItemMapper;
private TransientPayloadStorageService transientPayloadStorageService;
private CollectDataResultDetailCodec codec;
private CollectDataResultItemBatchWriter writer;
@BeforeEach
void setUp() {
taskResultItemMapper = mock(TaskResultItemMapper.class);
transientPayloadStorageService = mock(TransientPayloadStorageService.class);
codec = new CollectDataResultDetailCodec(new ObjectMapper());
writer = new CollectDataResultItemBatchWriter(taskResultItemMapper, codec, transientPayloadStorageService, 10);
}
@Test
void test_task_054_chunk_normal_default_path() {
// 正常路径:无现有行 → 2 行全部为真新增,newlyInserted=2(任务内累计基数)。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(2);
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike"), row("B000000002", "Zara")), "rustfs:detail/abc");
assertEquals(2, counts.newlyInserted(), "无存量时全部为新行");
assertEquals(2, counts.insertedOrUpdated(), "写入计数一致");
assertEquals(0, counts.skipped(), "无跳过");
}
@Test
void test_task_054_chunk_normal_multiple_items() {
// 批量场景(任务内累计):chunk0 已提交 B1/B2/B3 后,chunk1 再提交
// B3/B4/B5 → B3 跨 chunk 的 chunkIndex 不同 → 引用 hash 必然不同 → 走存量更新
// (行数不变,不计 newlyInserted);B4/B5 真新增 → newlyInserted=2。
// 任务内累计 3+2=5 等于表内最终行数,不依赖 COUNT(*)。
List<TaskResultItemEntity> existing = new ArrayList<>();
existing.add(existingItem("asin:B000000001", 0, 0, "rustfs:detail/x"));
existing.add(existingItem("asin:B000000002", 1, 0, "rustfs:detail/x"));
existing.add(existingItem("asin:B000000003", 2, 0, "rustfs:detail/x"));
when(taskResultItemMapper.selectList(any())).thenReturn(existing);
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 1,
List.of(row("B000000003", "Nike"), row("B000000004", "Zara"), row("B000000005", "Adidas")),
"rustfs:detail/x");
assertEquals(2, counts.newlyInserted(), "仅 B4/B5 为真新增,B3 跨 chunk 更新不新增行");
assertEquals(3, counts.insertedOrUpdated(), "3 行均触发写入(B3 更新 + B4/B5 插入)");
assertEquals(0, counts.skipped(), "跨 chunk hash 不同无跳过");
}
@Test
void test_task_054_chunk_normal_repeated_operation_is_idempotent() {
// 幂等:同一 chunk 完全重提(现有行 hash 全部相等)→ newlyInserted=0
// 不重复计数、不触发写入,finalRowCount 不虚高。
String refJson = codec.encodeRef(0, 0, "rustfs:detail/x");
TaskResultItemEntity existing = new TaskResultItemEntity();
existing.setId(100L);
existing.setItemKey("asin:B000000001");
existing.setPayloadJson(refJson);
existing.setPayloadHash(sha256(refJson));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(existing));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike")), "rustfs:detail/x");
assertEquals(0, counts.newlyInserted(), "重提不新增行");
assertEquals(0, counts.insertedOrUpdated(), "无写入");
assertEquals(1, counts.skipped(), "全部跳过");
verify(taskResultItemMapper, never()).upsertBatch(anyList());
}
@Test
void test_task_054_chunk_boundary_empty_input() {
// 空输入:空列表零调用,newlyInserted=0,任务内累计不变。
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, List.of(), "rustfs:x");
assertEquals(0, counts.newlyInserted(), "空输入不新增");
assertEquals(0, counts.insertedOrUpdated(), "无写入");
assertEquals(0, counts.skipped(), "无跳过");
verify(taskResultItemMapper, never()).selectList(any());
}
@Test
void test_task_054_chunk_boundary_single_item() {
// 单元素:单行真新增 → newlyInserted=1;单行存量更新(hash 不同)→ 0。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(1);
CollectDataResultItemBatchWriter.UpsertCounts insert =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike")), "rustfs:new");
assertEquals(1, insert.newlyInserted(), "单行真新增计 1");
TaskResultItemEntity existing = new TaskResultItemEntity();
existing.setId(100L);
existing.setItemKey("asin:B000000001");
existing.setPayloadJson("{\"brand\":\"nike\",\"asin\":\"B000000001\"}");
existing.setPayloadHash("old-hash");
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(existing));
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(1);
CollectDataResultItemBatchWriter.UpsertCounts update =
writer.upsertAccepted(1L, 2L, "task:1", 1,
List.of(row("B000000001", "Nike")), "rustfs:new");
assertEquals(0, update.newlyInserted(), "存量行更新(hash 不同)不新增行");
assertEquals(1, update.insertedOrUpdated(), "写入发生但行数不变");
}
@Test
void test_task_054_chunk_boundary_limit_and_overflow() {
// 上限/超限:75 行 8 批次全部真新增 → newlyInserted=75,无无界积累。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 75; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand-" + (i % 7)));
}
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 3, rows, "rustfs:x");
assertEquals(75, counts.newlyInserted(), "全量真新增");
assertEquals(75, counts.insertedOrUpdated(), "写入计数一致");
}
@Test
void test_task_054_chunk_invalid_input_rejected() {
// 非法参数:null 行 / null ASIN / 空白 ASIN 均跳过且不计数 → newlyInserted 只含合法行。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenAnswer(
invocation -> ((List<?>) invocation.getArgument(0)).size());
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row(null, "nike"));
rows.add(row(" ", "zara"));
rows.add(row("B000000001", "nike"));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(1, counts.newlyInserted(), "仅合法行计数");
assertEquals(1, counts.insertedOrUpdated(), "仅合法行写入");
}
@Test
void test_task_054_chunk_dependency_failure_releases_resources() {
// 依赖失败:首批 upsertBatch 抛错 → 该批真新增行未落库,newlyInserted 扣除;
// 恢复后重提 → 已落库行跳过、未落库行补插,任务内累计仍等于表内真实行数。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
doThrow(new RuntimeException("db down"))
.doAnswer(invocation -> ((List<?>) invocation.getArgument(0)).size())
.when(taskResultItemMapper).upsertBatch(anyList());
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 20; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "brand"));
}
CollectDataResultItemBatchWriter.UpsertCounts first =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(10, first.newlyInserted(), "首批失败扣除,仅第二批 10 行真新增");
// 重提同一 chunk:第二批 10 行 hash 相等跳过,首批 10 行补插 → newlyInserted=10。
List<TaskResultItemEntity> existing = new ArrayList<>();
for (int i = 10; i < 20; i++) {
String refJson = codec.encodeRef(0, i, "rustfs:x");
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setId(100L + i);
entity.setItemKey("asin:B" + String.format("%09d", i + 1));
entity.setPayloadJson(refJson);
entity.setPayloadHash(sha256(refJson));
existing.add(entity);
}
when(taskResultItemMapper.selectList(any())).thenReturn(existing);
CollectDataResultItemBatchWriter.UpsertCounts retry =
writer.upsertAccepted(1L, 2L, "task:1", 0, rows, "rustfs:x");
assertEquals(10, retry.newlyInserted(), "补插首批 10 行");
assertEquals(10, retry.skipped(), "第二批存量跳过");
assertEquals(20, first.newlyInserted() + retry.newlyInserted(), "任务内累计=表内真实行数");
}
private static TaskResultItemEntity existingItem(String itemKey, int offset, int chunkIndex, String pointer) {
TaskResultItemEntity entity = new TaskResultItemEntity();
entity.setId(100L + offset);
entity.setItemKey(itemKey);
String refJson = new CollectDataResultDetailCodec(new ObjectMapper()).encodeRef(chunkIndex, offset, pointer);
entity.setPayloadJson(refJson);
entity.setPayloadHash(sha256(refJson));
return entity;
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
private static String sha256(String value) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}