task-18: 统一图片 spool 生命周期,超时/取消/异常路径删除临时文件

- ImageSpool 增加 closed 状态:close 幂等、close 后 put 拒绝且消息可识别,
  杜绝关闭后再写入导致目录删除失败
- 长预算 prefetchToDisk 任务体补 put 前中断检查:deadline/中断取消后不再
  产生新临时文件,close 可完整删除目录(与 best-effort 路径对齐)
- 长预算路径并入测试 hook 探测,与短预算路径行为一致,生命周期可测
This commit is contained in:
2026-08-29 18:02:50 +08:00
parent e1217c9c85
commit 92858acb4d
2 changed files with 242 additions and 2 deletions
@@ -0,0 +1,226 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ImageSpool;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.Mockito.mock;
/**
* Task 18:统一图片 spool 生命周期,确保超时、取消和异常路径删除临时文件。
* - ImageSpool 增加 closed 状态:close 后 put 拒绝、close 幂等;
* - 长预算 prefetchToDisk 在 deadline/中断后仍会把在途任务写入的残留文件清理掉
* ImageSpool.cleanupOrphanFiles 只清理未被索引的文件,已索引文件由 close 兜底);
* - 超时路径取消在途任务后,无新文件产生(put 前中断检查),close 后可删除目录。
*/
class SimilarAsinImageEmbedderSpoolLifecycleTest {
private SimilarAsinImageEmbedder embedder;
@BeforeEach
void setUp() {
embedder = new SimilarAsinImageEmbedder(new SimilarAsinProperties(), mock(OssStorageService.class));
}
@AfterEach
void shutdown() {
embedder.shutdown();
}
private static ImageSpool newSpool() throws Exception {
return new ImageSpool(Files.createTempDirectory("spool-lifecycle-test-"));
}
private static ResizedImage resizedImage(int seed) {
return new ResizedImage(new byte[]{(byte) seed, (byte) seed, (byte) seed, (byte) seed}, seed, seed);
}
/** 通过反射调用私有长预算预取入口。 */
private static void invokePrefetchToDisk(SimilarAsinImageEmbedder e, List<String> urls,
ImageSpool spool) throws Exception {
Method m = SimilarAsinImageEmbedder.class.getDeclaredMethod(
"prefetchToDisk", java.util.Collection.class, ImageSpool.class);
m.setAccessible(true);
m.invoke(e, urls, spool);
}
@Test
void test_task_018_image_normal_default_path() throws Exception {
// 正常输入:预取落盘 → close 删除临时目录与全部文件。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg");
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(1));
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
invokePrefetchToDisk(embedder, urls, spool);
assertEquals(2, spool.size(), "预取结果全部落盘");
spool.close();
assertFalse(Files.exists(spool.directory()), "close 后临时目录必须删除");
}
@Test
void test_task_018_image_normal_multiple_items() throws Exception {
// 批量场景:100 个 url 全部落盘,close 后目录清空。
ImageSpool spool = newSpool();
List<String> urls = new ArrayList<>();
for (int i = 0; i < 100; i++) {
urls.add("https://img.example.com/multi-" + i + ".jpg");
final int idx = i;
embedder.registerPrefetchHandler(urls.get(i), () -> resizedImage(idx));
embedder.registerPrefetchResult(urls.get(i), resizedImage(idx));
}
invokePrefetchToDisk(embedder, urls, spool);
assertEquals(100, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()));
}
@Test
void test_task_018_image_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:close 幂等;已索引文件不重复写入(size 不增长)。
ImageSpool spool = newSpool();
String url = "https://img.example.com/idem.jpg";
embedder.registerPrefetchHandler(url, () -> resizedImage(7));
embedder.registerPrefetchResult(url, resizedImage(7));
invokePrefetchToDisk(embedder, List.of(url), spool);
assertEquals(1, spool.size());
spool.close();
spool.close();
assertFalse(Files.exists(spool.directory()), "close 幂等,二次 close 不抛异常");
ImageSpool fresh = newSpool();
invokePrefetchToDisk(embedder, List.of(url), fresh);
invokePrefetchToDisk(embedder, List.of(url), fresh);
assertEquals(1, fresh.size(), "重复预取同一 url 不产生重复文件");
fresh.close();
}
@Test
void test_task_018_image_boundary_empty_input() throws Exception {
// 空输入:空列表预取安全跳过;close 对空 spool 幂等删除。
ImageSpool spool = newSpool();
invokePrefetchToDisk(embedder, List.of(), spool);
invokePrefetchToDisk(embedder, null, spool);
assertEquals(0, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()));
}
@Test
void test_task_018_image_boundary_single_item() throws Exception {
// 单 url:不依赖批量路径,close 后目录删除。
ImageSpool spool = newSpool();
String url = "https://img.example.com/single.jpg";
embedder.registerPrefetchHandler(url, () -> resizedImage(3));
embedder.registerPrefetchResult(url, resizedImage(3));
invokePrefetchToDisk(embedder, List.of(url), spool);
assertEquals(1, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()));
}
@Test
void test_task_018_image_boundary_limit_and_overflow() throws Exception {
// 超时/取消:阻塞 handler 在 deadline 后被取消,取消后不产生新文件;
// 取消瞬间已在途的写入文件由 close 兜底清理,目录可完整删除。
ImageSpool spool = newSpool();
String slow = "https://img.example.com/slow.jpg";
embedder.registerPrefetchHandler(slow, () -> {
try {
TimeUnit.SECONDS.sleep(30L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread caller = new Thread(() -> {
try {
invokePrefetchToDisk(embedder, List.of(slow), spool);
} catch (Exception ignored) {
// 中断或超时路径允许异常
}
});
caller.start();
caller.join(TimeUnit.SECONDS.toMillis(2));
caller.interrupt();
caller.join(TimeUnit.SECONDS.toMillis(5));
try (var paths = Files.walk(spool.directory())) {
long files = paths.filter(Files::isRegularFile).count();
spool.close();
assertFalse(Files.exists(spool.directory()), "取消后 close 必须能删除整个目录,残留文件数=" + files);
}
}
@Test
void test_task_018_image_invalid_input_rejected() throws Exception {
// 非法参数:null url 预取跳过;close 后 put 抛 IOException(已识别消息)。
ImageSpool spool = newSpool();
invokePrefetchToDisk(embedder, java.util.Arrays.asList(null, " "), spool);
assertEquals(0, spool.size());
spool.close();
IOException ioEx = assertThrows(IOException.class,
() -> spool.put("https://img.example.com/late.jpg", resizedImage(9)));
assertTrue(ioEx.getMessage().contains("closed"), "close 后写入应拒绝,消息含 closed");
assertNull(spool.get("https://img.example.com/late.jpg"), "close 后 get 返回 null");
assertTrue(spool.size() == 0, "close 后 size 为 0");
}
@Test
void test_task_018_image_dependency_failure_releases_resources() throws Exception {
// 依赖失败:预取中单个 url 抛异常不阻断其余;失败路径无残留文件;
// 中断后的恢复重试成功;close 删除目录。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/fail.jpg", "https://img.example.com/ok.jpg");
AtomicInteger failCalls = new AtomicInteger(0);
embedder.registerPrefetchHandler(urls.get(0), () -> {
if (failCalls.getAndIncrement() == 0) {
throw new IllegalStateException("http down");
}
});
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
invokePrefetchToDisk(embedder, urls, spool);
assertNull(spool.get(urls.get(0)), "失败 url 不落 spool");
assertNotNull(spool.get(urls.get(1)), "正常 url 正常落 spool");
assertEquals(1, spool.size(), "失败路径不残留文件");
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
invokePrefetchToDisk(embedder, List.of(urls.get(0)), spool);
assertNotNull(spool.get(urls.get(0)), "恢复后重试成功");
assertEquals(2, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()), "失败+恢复后 close 仍能删除目录");
}
}