diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixture.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixture.java index af5be0c1..987a6e57 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixture.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixture.java @@ -1,14 +1,17 @@ package com.nanri.aiimage.modules.similarasin.util; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo; import lombok.extern.slf4j.Slf4j; +import java.lang.management.ManagementFactory; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * Similar ASIN 性能基线夹具:生成 1000/5000 行解析数据、图片开关两种模式、 @@ -20,6 +23,7 @@ public class SimilarAsinPerfFixture { public static final int MAX_ROWS = 5000; public static final int DEFAULT_CHUNK_SIZE = 200; + private static final int GC_ROUNDS = 10; private static final String[] COUNTRIES = {"英国", "德国", "法国", "意大利", "西班牙"}; private static final String[] TITLES = { @@ -126,6 +130,137 @@ public class SimilarAsinPerfFixture { public record Metrics(int rowCount, int chunkCount, long payloadBytes) { } + /** + * 端到端基准:生成 → 分 chunk → 序列化全量 payload,记录行数、chunk 数、 + * payload 字节、组装耗时(毫秒)、吞吐(行/秒)与峰值堆(字节)。 + * 依赖失败(序列化抛异常)时向上抛出 IllegalStateException,不产生部分结果。 + */ + public EndToEndMetrics endToEndBenchmark(String sourceFileKey, int rowCount, boolean withImages, int chunkSize) { + List rows = generateRows(sourceFileKey, rowCount, withImages); + List> chunks = splitChunks(rows, chunkSize); + if (rows.isEmpty()) { + return new EndToEndMetrics(0, 0, 0, 0, 0, 0); + } + long startMillis = System.currentTimeMillis(); + long peakHeapBefore = sampledPeakHeapBytes(); + long payloadBytes; + try { + byte[] bytes = objectMapper.writeValueAsBytes(rows); + payloadBytes = bytes.length; + } catch (Exception ex) { + throw new IllegalStateException("端到端基准序列化失败", ex); + } + long elapsedMillis = Math.max(1L, System.currentTimeMillis() - startMillis); + long peakHeap = Math.max(peakHeapBefore, sampledPeakHeapBytes()); + double throughput = rows.size() * 1000.0 / elapsedMillis; + return new EndToEndMetrics(rows.size(), chunks.size(), payloadBytes, elapsedMillis, throughput, peakHeap); + } + + /** + * GC 压力分析:多轮生成/序列化/释放循环,每轮记录 GC 计数差与堆峰值。 + * 返回最后采样样本;rounds 表示实际执行轮数。临时对象随轮释放,堆峰值有界。 + */ + public GcStressSample gcStressAnalysis(String sourceFileKey, int rowCount, boolean withImages) { + if (sourceFileKey == null || sourceFileKey.isBlank()) { + throw new IllegalArgumentException("sourceFileKey 不能为空"); + } + if (rowCount < 0 || rowCount > MAX_ROWS) { + throw new IllegalArgumentException("rowCount 必须在 [0, " + MAX_ROWS + "] 范围内,实际 " + rowCount); + } + long before = totalGcCount(); + long peak = sampledPeakHeapBytes(); + int rounds = 0; + for (int round = 1; round <= GC_ROUNDS; round++) { + List rows = generateRows(sourceFileKey, rowCount, withImages); + if (!rows.isEmpty()) { + try { + objectMapper.writeValueAsBytes(rows); + } catch (Exception ex) { + throw new IllegalStateException("GC 压力分析序列化失败", ex); + } + } + rounds++; + peak = Math.max(peak, sampledPeakHeapBytes()); + } + return new GcStressSample(rounds, totalGcCount() - before, peak); + } + + /** payload 序列化往返兼容回归:序列化全量行 → 反序列化恢复 → 校验行数一致与字段稳定。 */ + public CompatResult compatRoundTrip(String sourceFileKey, int rowCount, boolean withImages) { + List rows = generateRows(sourceFileKey, rowCount, withImages); + if (rows.isEmpty()) { + return new CompatResult(0, 0, true); + } + byte[] bytes; + try { + bytes = objectMapper.writeValueAsBytes(rows); + } catch (Exception ex) { + throw new IllegalStateException("兼容回归序列化失败", ex); + } + List recovered; + try { + recovered = objectMapper.readValue(bytes, new TypeReference>() { + }); + } catch (Exception ex) { + throw new IllegalStateException("兼容回归反序列化失败", ex); + } + return new CompatResult(rows.size(), recovered.size(), fieldsStable(rows, recovered)); + } + + public record EndToEndMetrics(int rowCount, int chunkCount, long payloadBytes, long assembleMillis, + double throughputRowsPerSec, long peakHeapBytes) { + } + + public record GcStressSample(int rounds, long gcCount, long peakHeapBytes) { + } + + public record CompatResult(int rowCount, int recoveredCount, boolean fieldStable) { + } + + private static boolean fieldsStable(List original, List recovered) { + if (original.size() != recovered.size()) { + return false; + } + for (int i = 0; i < original.size(); i++) { + SimilarAsinParsedRowVo a = original.get(i); + SimilarAsinParsedRowVo b = recovered.get(i); + if (!Objects.equals(a.getAsin(), b.getAsin()) + || !Objects.equals(a.getCountry(), b.getCountry()) + || !Objects.equals(a.getSku(), b.getSku()) + || !Objects.equals(a.getTitle(), b.getTitle()) + || !Objects.equals(a.getUrl(), b.getUrl()) + || !Objects.equals(a.getRowToken(), b.getRowToken()) + || !Objects.equals(a.getSourceFileKey(), b.getSourceFileKey())) { + return false; + } + } + return true; + } + + /** 采样当前堆已用字节峰值;MXBean 不可用时返回 -1,与 "> 0" 类断言不冲突(GC 场景始终可用)。 */ + private static long sampledPeakHeapBytes() { + try { + return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); + } catch (Exception ex) { + return -1; + } + } + + private static long totalGcCount() { + long total = 0; + try { + for (java.lang.management.GarbageCollectorMXBean bean : ManagementFactory.getGarbageCollectorMXBeans()) { + long count = bean.getCollectionCount(); + if (count >= 0) { + total += count; + } + } + } catch (Exception ex) { + return 0; + } + return total; + } + private static String deterministicAsin(long seed) { StringBuilder sb = new StringBuilder("B0"); long state = seed; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureE2ETest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureE2ETest.java new file mode 100644 index 00000000..c9f71b92 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureE2ETest.java @@ -0,0 +1,172 @@ +package com.nanri.aiimage.modules.similarasin.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +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.Mockito.doAnswer; + +/** + * Task 20:Similar ASIN 端到端压测、JFR/GC 分析与结果文件兼容回归。 + * SimilarAsinPerfFixture 新增三个功能点: + * - endToEndBenchmark:生成 → 分 chunk → 序列化 → 计时 → 吞吐与峰值堆采样; + * - gcStressAnalysis:多轮生成/释放循环采样 GC 计数与堆峰值; + * - compatRoundTrip:payload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。 + */ +@ExtendWith(MockitoExtension.class) +class SimilarAsinPerfFixtureE2ETest { + + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void test_task_020_asin_normal_default_path() { + // 正常输入:1000 行端到端基准返回完整指标,行数/chunk 数正确,吞吐与堆峰值有界。 + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + SimilarAsinPerfFixture.EndToEndMetrics metrics = + fixture.endToEndBenchmark("uploads/20260829/e2e-default.xlsx", 1000, false, 200); + + assertEquals(1000, metrics.rowCount(), "行数不丢失"); + assertEquals(5, metrics.chunkCount(), "1000 行 / 200 每 chunk = 5 个 chunk"); + assertTrue(metrics.payloadBytes() > 0, "payload 字节可采样"); + assertTrue(metrics.assembleMillis() >= 0); + assertTrue(metrics.throughputRowsPerSec() > 0, "吞吐必须为正"); + assertTrue(metrics.peakHeapBytes() > 0, "峰值堆必须为正"); + } + + @Test + void test_task_020_asin_normal_multiple_items() { + // 批量场景:图片开/关两种模式 5000 行,结果不丢失、chunk 划分稳定。 + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + SimilarAsinPerfFixture.EndToEndMetrics withImages = + fixture.endToEndBenchmark("uploads/20260829/e2e-img.xlsx", 5000, true, 200); + SimilarAsinPerfFixture.EndToEndMetrics textOnly = + fixture.endToEndBenchmark("uploads/20260829/e2e-text.xlsx", 5000, false, 200); + + assertEquals(5000, withImages.rowCount()); + assertEquals(5000, textOnly.rowCount()); + assertEquals(25, withImages.chunkCount()); + assertEquals(25, textOnly.chunkCount()); + assertTrue(withImages.payloadBytes() > textOnly.payloadBytes(), "图片模式 payload 必须大于纯文本"); + } + + @Test + void test_task_020_asin_normal_repeated_operation_is_idempotent() throws Exception { + // 重复执行:同一输入两次基准的指标一致,不产生重复行。 + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + SimilarAsinPerfFixture.CompatResult first = + fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true); + SimilarAsinPerfFixture.CompatResult second = + fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true); + + assertEquals(1000, first.rowCount()); + assertEquals(1000, first.recoveredCount(), "往返恢复全量行"); + assertTrue(first.fieldStable(), "字段必须稳定"); + assertEquals(first.rowCount(), second.rowCount()); + assertEquals(first.recoveredCount(), second.recoveredCount()); + assertEquals(first.fieldStable(), second.fieldStable()); + } + + @Test + void test_task_020_asin_boundary_empty_input() { + // 空输入:0 行基准返回零指标;0 行往返返回空结果,不创建资源。 + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + SimilarAsinPerfFixture.EndToEndMetrics metrics = + fixture.endToEndBenchmark("uploads/20260829/e2e-empty.xlsx", 0, false, 200); + assertEquals(0, metrics.rowCount()); + assertEquals(0, metrics.chunkCount()); + assertEquals(0, metrics.payloadBytes()); + + SimilarAsinPerfFixture.CompatResult compat = + fixture.compatRoundTrip("uploads/20260829/e2e-empty.xlsx", 0, false); + assertEquals(0, compat.rowCount()); + assertEquals(0, compat.recoveredCount()); + assertTrue(compat.fieldStable(), "空结果字段稳定"); + } + + @Test + void test_task_020_asin_boundary_single_item() { + // 单元素:1 行基准不依赖批量路径,往返字段一致。 + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + SimilarAsinPerfFixture.EndToEndMetrics metrics = + fixture.endToEndBenchmark("uploads/20260829/e2e-single.xlsx", 1, true, 200); + assertEquals(1, metrics.rowCount()); + assertEquals(1, metrics.chunkCount()); + + SimilarAsinPerfFixture.CompatResult compat = + fixture.compatRoundTrip("uploads/20260829/e2e-single.xlsx", 1, true); + assertEquals(1, compat.recoveredCount()); + assertTrue(compat.fieldStable()); + } + + @Test + void test_task_020_asin_boundary_limit_and_overflow() { + // 上限/超限:超过 MAX_ROWS 拒绝;5000 行基准在预算内完成,不发生无界内存增长。 + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + IllegalArgumentException overflow = assertThrows(IllegalArgumentException.class, + () -> fixture.endToEndBenchmark("uploads/20260829/e2e-over.xlsx", + SimilarAsinPerfFixture.MAX_ROWS + 1, false, 200)); + assertTrue(overflow.getMessage().contains("rowCount"), "超限消息应可识别"); + assertThrows(IllegalArgumentException.class, + () -> fixture.compatRoundTrip("uploads/20260829/e2e-over.xlsx", + SimilarAsinPerfFixture.MAX_ROWS + 1, false)); + + SimilarAsinPerfFixture.EndToEndMetrics metrics = + fixture.endToEndBenchmark("uploads/20260829/e2e-max.xlsx", 5000, true, 200); + assertTrue(metrics.assembleMillis() < 15000, + "5000 行端到端基准须在预算内完成,实际=" + metrics.assembleMillis() + "ms"); + assertTrue(metrics.peakHeapBytes() < 1024L * 1024L * 1024L, "峰值堆不得超过 1GB"); + } + + @Test + void test_task_020_asin_invalid_input_rejected() { + // 非法参数:null key/非法 chunkSize/非法 GC 行数 → 明确异常与可识别消息。 + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + IllegalArgumentException nullKey = assertThrows(IllegalArgumentException.class, + () -> fixture.endToEndBenchmark(null, 100, false, 200)); + assertTrue(nullKey.getMessage().contains("sourceFileKey")); + assertThrows(IllegalArgumentException.class, + () -> fixture.endToEndBenchmark("uploads/20260829/x.xlsx", 100, false, 0)); + assertThrows(IllegalArgumentException.class, + () -> fixture.gcStressAnalysis(null, 100, false)); + assertThrows(IllegalArgumentException.class, + () -> fixture.gcStressAnalysis("uploads/20260829/x.xlsx", -1, false)); + } + + @Test + void test_task_020_asin_dependency_failure_releases_resources() throws Exception { + // 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果;恢复后重试成功; + // GC 分析后堆峰值回落(临时对象释放)。 + AtomicInteger failCount = new AtomicInteger(0); + doAnswer(invocation -> { + if (failCount.getAndIncrement() == 0) { + throw new IOException("rustfs down"); + } + return invocation.callRealMethod(); + }).when(objectMapper).writeValueAsBytes(any()); + + SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper); + assertThrows(IllegalStateException.class, + () -> fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200)); + SimilarAsinPerfFixture.EndToEndMetrics recovered = + fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200); + assertEquals(100, recovered.rowCount(), "依赖恢复后重试成功"); + + SimilarAsinPerfFixture.GcStressSample gc = + fixture.gcStressAnalysis("uploads/20260829/e2e-gc.xlsx", 1000, true); + assertNotNull(gc); + assertTrue(gc.rounds() >= 1, "GC 分析至少执行一轮"); + assertTrue(gc.gcCount() >= 0); + assertTrue(gc.peakHeapBytes() > 0, "堆峰值必须可采样"); + assertTrue(gc.peakHeapBytes() < 1024L * 1024L * 1024L, "GC 分析峰值堆不得超过 1GB"); + } +}