task-16: 图片预取改短预算 best-effort,超时取消在途并回退 URL

prefetchToDiskBestEffort 在 budgetSeconds 内尽力预取,预算耗尽即取消
在途任务并返回未预取数量;缺图单元格由既有 fallback 直接写 URL。
put 前检查中断标志,避免取消后写盘与 spool close 竞态产生残留文件。
预算通过 aiimage.similar-asin.image-prefetch-budget-seconds 配置(默认 60s),
长预算后台预热路径 prefetchToDisk 行为不变。
This commit is contained in:
2026-08-29 17:37:11 +08:00
parent f517b7585f
commit 0e3dbe450f
3 changed files with 340 additions and 0 deletions
@@ -186,6 +186,13 @@ public class SimilarAsinProperties {
*/
private int imageCacheTouchFlushThreshold = 1000;
/**
* Task 16:图片预取短预算(秒)。assemble 阶段预取在预算内 best-effort
* 尽力完成,预算耗尽即取消在途任务并回退 URL,避免整批预取拖垮结果组装。
* 该值仅作用于 assemble 阶段预取;后台预热仍使用 imagePrefetchTimeoutSeconds。
*/
private int imagePrefetchBudgetSeconds = 60;
/**
* P0-4:抢 Coze 提交锁失败后下次重试间隔(毫秒)。
* 原硬编码 500ms,会在指数退避算法中作为基础值(500/1000/2000/4000ms 上限 4000)。
@@ -167,6 +167,32 @@ public class SimilarAsinImageEmbedder {
downloadPool.shutdownNow();
}
/** Task 16:测试专用 hook,按 url 注入预取处理,绕过真实 HTTP 下载。 */
private final ConcurrentMap<String, Runnable> testPrefetchHandlers = new ConcurrentHashMap<>();
void registerPrefetchHandler(String url, Runnable handler) {
if (url != null && handler != null) {
testPrefetchHandlers.put(url.trim(), handler);
}
}
private ResizedImage fetchAndResizeDirectForTest(String url) {
Runnable handler = testPrefetchHandlers.get(url.trim());
if (handler != null) {
handler.run();
return testPrefetchResults.get(url.trim());
}
return null;
}
private final ConcurrentMap<String, ResizedImage> testPrefetchResults = new ConcurrentHashMap<>();
void registerPrefetchResult(String url, ResizedImage image) {
if (url != null && image != null) {
testPrefetchResults.put(url.trim(), image);
}
}
static int cpuBoundPoolLimit() {
int visibleProcessors = Math.max(1, Runtime.getRuntime().availableProcessors());
return Math.max(1, (visibleProcessors + 1) / 2);
@@ -349,6 +375,90 @@ public class SimilarAsinImageEmbedder {
failed.get(), skipped, deadlineReached);
}
/**
* Task 16:短预算 best-effort 预取。在 budgetSeconds 内尽力下载 resize
* 预算耗尽即停止并取消在途任务;缺图由调用方回退为 URL。
* 返回未预取(skipped)数量。单个 url 失败不阻断其余 url。
*/
int prefetchToDiskBestEffort(Collection<String> urls, ImageSpool imageSpool, long budgetSeconds) {
if (urls == null || urls.isEmpty() || imageSpool == null || budgetSeconds <= 0L) {
return 0;
}
Set<String> distinctUrls = new LinkedHashSet<>();
for (String url : urls) {
if (url == null) {
continue;
}
String trimmed = url.trim();
if (!trimmed.isEmpty() && imageSpool.get(trimmed) == null) {
distinctUrls.add(trimmed);
}
}
if (distinctUrls.isEmpty()) {
return 0;
}
long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(budgetSeconds);
AtomicInteger completed = new AtomicInteger();
CompletionService<Object> completion = new ExecutorCompletionService<>(downloadPool);
Iterator<String> pending = distinctUrls.iterator();
List<Future<?>> active = new ArrayList<>(Math.min(downloadPoolSize, distinctUrls.size()));
while (pending.hasNext() && active.size() < downloadPoolSize) {
active.add(submitBestEffortPrefetch(completion, pending.next(), imageSpool));
}
boolean deadlineReached = false;
while (!active.isEmpty()) {
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos <= 0L) {
deadlineReached = true;
break;
}
try {
Future<Object> finished = completion.poll(remainingNanos, TimeUnit.NANOSECONDS);
if (finished == null) {
deadlineReached = true;
break;
}
active.remove(finished);
completed.incrementAndGet();
if (pending.hasNext()) {
active.add(submitBestEffortPrefetch(completion, pending.next(), imageSpool));
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
cancelAll(active);
return distinctUrls.size() - completed.get();
}
}
if (deadlineReached) {
cancelAll(active);
}
int skipped = Math.max(0, distinctUrls.size() - completed.get());
log.info("[similar-asin][image] disk prefetch best-effort finished total={} completed={} spooled={} skipped={} budgetSeconds={}",
distinctUrls.size(), completed.get(), imageSpool.size(), skipped, budgetSeconds);
return skipped;
}
private Future<?> submitBestEffortPrefetch(CompletionService<Object> completion,
String url,
ImageSpool imageSpool) {
return completion.submit(() -> {
try {
ResizedImage thumb = readLocalCachedThumb(url);
if (thumb == null) {
thumb = fetchAndResizeDirectForTest(url);
if (thumb == null) {
thumb = fetchAndResizeDirect(url);
}
}
ensureImageWorkNotInterrupted();
imageSpool.put(url, thumb);
} catch (Exception ex) {
log.debug("[similar-asin][image] best-effort prefetch fail url={} err={}", url, errorSummary(ex));
}
return null;
});
}
private Future<?> submitDiskPrefetch(CompletionService<Object> completion,
String url,
ImageSpool imageSpool,