新增内容,新增拉起软件层

This commit is contained in:
super
2026-06-08 09:19:15 +08:00
parent 8ab647c419
commit c85e5b278f
67 changed files with 1552 additions and 189 deletions

View File

@@ -83,6 +83,7 @@ public class SimilarAsinProperties {
* 出现淘汰过频影响命中率时可上调到 512MB2GB 堆约束下不建议超过 768MB。
*/
private long imageCacheMaxBytes = 256L * 1024L * 1024L;
private boolean imageDbCacheEnabled = false;
/**
* 是否在 Coze 请求 parameters 中附带 api_key 字段。

View File

@@ -12,4 +12,6 @@ public class TaskImageCacheCleanupProperties {
private int retentionDays = 3;
private int batchSize = 5000;
private int maxBatchesPerRun = 200;
private long maxBytes = 2L * 1024L * 1024L * 1024L;
private long targetBytes = 1L * 1024L * 1024L * 1024L;
}

View File

@@ -17,4 +17,25 @@ public class TransientStorageProperties {
private int writeTimeoutSeconds = 60;
private int uploadMaxRetries = 3;
private int readMaxRetries = 3;
private int deleteMaxRetries = 3;
private int maxConcurrentUploads = 16;
private int maxConcurrentReads = 32;
private int maxConcurrentDeletes = 8;
private long acquirePermitTimeoutMillis = 2000;
private long baseRetryDelayMillis = 500;
private long maxRetryDelayMillis = 5000;
private long retryJitterMillis = 250;
private long failureWindowSeconds = 60;
private int failureWindowThreshold = 20;
private long failureCooldownMillis = 10000;
private int connectionPoolMaxIdle = 0;
private long connectionPoolKeepAliveMillis = 1;
private long warnPayloadBytes = 5L * 1024 * 1024;
private long maxPayloadBytes = 50L * 1024 * 1024;
private long maxStoredPayloadBytes = 50L * 1024 * 1024;
private boolean fallbackToLocalOnOversize = true;
private boolean deleteRetryEnabled = true;
private String deleteRetryCron = "0 */5 * * * *";
private int deleteRetryQueueCapacity = 10000;
private int deleteRetryBatchSize = 200;
}

View File

@@ -33,6 +33,7 @@ public class AppearancePatentCozeClient {
private static final String INFRINGEMENT = "侵权";
private static final String NO_INFRINGEMENT = "无侵权";
private static final String BRAND_QUERY_FAILED = "商标查询失败";
private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时";
private final AppearancePatentProperties properties;
private final ObjectMapper objectMapper;
@@ -691,6 +692,18 @@ public class AppearancePatentCozeClient {
}
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
if (isCozeAsyncPollTimeout(failureMessage)) {
row.setError(failureMessage);
if (row.getStatus() == null || row.getStatus().isBlank()) {
row.setStatus("FAILED");
row.setFailureSyntheticStatus(true);
}
row.setTitleRisk(null);
row.setAppearanceRisk(null);
row.setPatentRisk(null);
row.setConclusion(null);
return row;
}
String reviewMessage = failureMessage == null || failureMessage.isBlank()
? "Coze 检测失败"
: "Coze 检测失败:" + failureMessage;
@@ -718,6 +731,12 @@ public class AppearancePatentCozeClient {
return row;
}
private boolean isCozeAsyncPollTimeout(String failureMessage) {
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT))
|| normalized.contains("coze async workflow poll timeout");
}
private boolean shouldSplitBatch(List<AppearancePatentResultRowDto> rows, Exception ex) {
return rows != null && rows.size() > 1 && isRetryableBatchFailure(ex);
}

View File

@@ -102,6 +102,7 @@ public class AppearancePatentTaskService {
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时";
private static final String COZE_EMPTY_RESULT_MESSAGE = "Coze returned empty result rows";
private static final String COZE_STATUS_SUBMITTED = "SUBMITTED";
private static final String COZE_STATUS_RUNNING = "RUNNING";
@@ -4193,6 +4194,9 @@ public class AppearancePatentTaskService {
}
private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) {
if (row != null && isCozeAsyncPollTimeout(row.getError())) {
return "";
}
String normalizedValue = normalize(value);
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
return value;
@@ -4211,6 +4215,9 @@ public class AppearancePatentTaskService {
if (row == null) {
return "";
}
if (isCozeAsyncPollTimeout(row.getError())) {
return "";
}
String conclusion = normalize(row.getConclusion());
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
return row.getConclusion();
@@ -4225,6 +4232,12 @@ public class AppearancePatentTaskService {
return firstNonBlank(row.getConclusion(), "");
}
private boolean isCozeAsyncPollTimeout(String value) {
String normalized = normalize(value).toLowerCase(Locale.ROOT);
return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT))
|| normalized.contains("coze async workflow poll timeout");
}
private String userFacingStatus(AppearancePatentResultRowDto row) {
if (row == null) {
return "";

View File

@@ -0,0 +1,149 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
@Service
@RequiredArgsConstructor
@Slf4j
public class RustfsDeleteRetryService {
private final TransientStorageProperties properties;
private final RustfsObjectStorageService rustfsObjectStorageService;
private final ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
private final ConcurrentHashMap<String, RetryItem> pending = new ConcurrentHashMap<>();
private final AtomicInteger queuedCount = new AtomicInteger();
public void enqueue(String objectKey, Throwable cause) {
if (!properties.isDeleteRetryEnabled() || objectKey == null || objectKey.isBlank()) {
return;
}
RetryItem item = pending.computeIfAbsent(objectKey, RetryItem::new);
item.lastError = cause == null ? null : cause.getMessage();
item.lastFailedAt = LocalDateTime.now();
enqueueItem(item, false);
}
@Scheduled(cron = "${aiimage.transient-storage.delete-retry-cron:0 */5 * * * *}")
public void retryPendingDeletes() {
if (!properties.isDeleteRetryEnabled()) {
return;
}
int batchSize = Math.max(1, properties.getDeleteRetryBatchSize());
int processed = 0;
int success = 0;
int failed = 0;
while (processed < batchSize) {
String objectKey = nextQueuedObjectKey(batchSize - processed);
if (objectKey == null) {
break;
}
RetryItem item = pending.get(objectKey);
if (item == null) {
continue;
}
item.queued = false;
processed++;
try {
rustfsObjectStorageService.deleteObjectFromRetry(objectKey);
pending.remove(objectKey);
success++;
} catch (Exception ex) {
failed++;
int failures = item.failures.incrementAndGet();
item.lastError = ex.getMessage();
item.lastFailedAt = LocalDateTime.now();
log.warn("[rustfs] delete retry failed objectKey={} failures={} err={}", objectKey, failures, ex.getMessage());
enqueueItem(item, true);
}
}
if (processed > 0) {
log.info("[rustfs] delete retry batch completed processed={} success={} failed={} pending={} queued={}",
processed, success, failed, pending.size(), queuedCount.get());
}
}
int pendingCount() {
return pending.size();
}
private String nextQueuedObjectKey(int refillLimit) {
String objectKey = queue.poll();
if (objectKey != null) {
decrementQueuedCount();
refillQueue(refillLimit);
return objectKey;
}
if (refillQueue(refillLimit) <= 0) {
return null;
}
objectKey = queue.poll();
if (objectKey != null) {
decrementQueuedCount();
refillQueue(refillLimit);
}
return objectKey;
}
private int refillQueue(int maxItems) {
if (maxItems <= 0) {
return 0;
}
int capacity = Math.max(1, properties.getDeleteRetryQueueCapacity());
if (queuedCount.get() >= capacity) {
return 0;
}
int added = 0;
for (RetryItem item : pending.values()) {
if (added >= maxItems || queuedCount.get() >= capacity) {
break;
}
if (enqueueItem(item, true)) {
added++;
}
}
return added;
}
private void decrementQueuedCount() {
queuedCount.updateAndGet(value -> Math.max(0, value - 1));
}
private boolean enqueueItem(RetryItem item, boolean requeue) {
synchronized (item) {
if (item.queued) {
return false;
}
int capacity = Math.max(1, properties.getDeleteRetryQueueCapacity());
if (queuedCount.get() >= capacity) {
log.error("[rustfs] delete retry queue full, deferred objectKey={} requeue={} pending={} queued={} capacity={} lastError={}",
item.objectKey, requeue, pending.size(), queuedCount.get(), capacity, item.lastError);
return false;
}
item.queued = true;
queue.offer(item.objectKey);
queuedCount.incrementAndGet();
return true;
}
}
private static class RetryItem {
private final String objectKey;
private final AtomicInteger failures = new AtomicInteger();
private volatile boolean queued;
private volatile String lastError;
private volatile LocalDateTime lastFailedAt;
private RetryItem(String objectKey) {
this.objectKey = objectKey;
}
}
}

View File

@@ -1,29 +1,71 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.StatObjectArgs;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
@Service
@RequiredArgsConstructor
@Slf4j
public class RustfsObjectStorageService {
private static final String OP_UPLOAD = "upload";
private static final String OP_READ = "read";
private static final String OP_DELETE = "delete";
private static final String OP_STAT = "stat";
private final TransientStorageProperties properties;
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
private final ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider;
private final Supplier<MinioClient> minioClientSupplier;
private final Semaphore uploadSemaphore;
private final Semaphore readSemaphore;
private final Semaphore deleteSemaphore;
private final AtomicInteger windowFailureCount = new AtomicInteger();
private volatile long failureWindowStartedAtMillis;
private volatile long circuitOpenUntilMillis;
private volatile OkHttpClient httpClient;
@Autowired
public RustfsObjectStorageService(TransientStorageProperties properties,
ObjectProvider<MeterRegistry> meterRegistryProvider,
ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider) {
this(properties, meterRegistryProvider, deleteRetryServiceProvider, null);
}
RustfsObjectStorageService(TransientStorageProperties properties,
ObjectProvider<MeterRegistry> meterRegistryProvider,
ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider,
Supplier<MinioClient> minioClientSupplier) {
this.properties = properties;
this.meterRegistryProvider = meterRegistryProvider;
this.deleteRetryServiceProvider = deleteRetryServiceProvider;
this.minioClientSupplier = minioClientSupplier;
this.uploadSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentUploads()));
this.readSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentReads()));
this.deleteSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentDeletes()));
}
public boolean isConfigured() {
return notBlank(properties.getEndpoint())
&& notBlank(properties.getBucket())
@@ -39,10 +81,10 @@ public class RustfsObjectStorageService {
if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured");
}
rejectIfCircuitOpen(OP_UPLOAD, objectKey);
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
Exception last = null;
int maxRetries = Math.max(1, properties.getUploadMaxRetries());
for (int attempt = 1; attempt <= maxRetries; attempt++) {
recordPayloadBytes(bytes.length);
return executeWithRetry(OP_UPLOAD, objectKey, bytes.length, Math.max(1, properties.getUploadMaxRetries()), uploadSemaphore, () -> {
try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) {
buildClient().putObject(PutObjectArgs.builder()
.bucket(properties.getBucket())
@@ -54,56 +96,117 @@ public class RustfsObjectStorageService {
verifyObjectVisible(objectKey);
}
return objectKey;
} catch (Exception ex) {
last = ex;
if (attempt < maxRetries) {
log.warn("[rustfs] upload failed, retrying objectKey={} attempt={}/{}", objectKey, attempt, maxRetries, ex);
sleepQuietly(500L * attempt);
}
}
}
throw new IllegalStateException("failed to upload payload to transient storage", last);
});
}
public String readObjectAsString(String objectKey) {
if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured");
}
Exception last = null;
int maxRetries = Math.max(1, properties.getReadMaxRetries());
for (int attempt = 1; attempt <= maxRetries; attempt++) {
rejectIfCircuitOpen(OP_READ, objectKey);
return executeWithRetry(OP_READ, objectKey, 0, Math.max(1, properties.getReadMaxRetries()), readSemaphore, () -> {
try (var stream = buildClient().getObject(GetObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build())) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception ex) {
last = ex;
if (attempt < maxRetries) {
// 与 uploadText 对齐的线性退避,避免 rustfs 短暂抖动直接打成 read coze batch failed。
log.warn("[rustfs] read failed, retrying objectKey={} attempt={}/{} err={}", objectKey, attempt, maxRetries, ex.getMessage());
sleepQuietly(500L * attempt);
}
}
}
throw new IllegalStateException("failed to read payload from transient storage", last);
});
}
public void deleteObject(String objectKey) {
deleteObject(objectKey, true);
}
void deleteObjectFromRetry(String objectKey) {
deleteObject(objectKey, false);
}
private void deleteObject(String objectKey, boolean enqueueRetryOnFailure) {
if (!isConfigured() || !notBlank(objectKey)) {
return;
}
try {
buildClient().removeObject(RemoveObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build());
} catch (Exception ex) {
throw new IllegalStateException("failed to delete payload from transient storage", ex);
executeWithRetry(OP_DELETE, objectKey, 0, Math.max(1, properties.getDeleteMaxRetries()), deleteSemaphore, () -> {
buildClient().removeObject(RemoveObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build());
return null;
});
} catch (RuntimeException ex) {
if (enqueueRetryOnFailure && properties.isDeleteRetryEnabled()) {
RustfsDeleteRetryService retryService = deleteRetryServiceProvider == null ? null : deleteRetryServiceProvider.getIfAvailable();
if (retryService != null) {
retryService.enqueue(objectKey, ex);
}
}
throw ex;
}
}
private <T> T executeWithRetry(String operation,
String objectKey,
long bytes,
int maxRetries,
Semaphore semaphore,
CheckedSupplier<T> supplier) {
acquirePermit(operation, objectKey, semaphore);
long startedAt = System.nanoTime();
Exception last = null;
try {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
T result = supplier.get();
resetFailureWindow();
recordOperation(operation, "success", elapsedNanos(startedAt));
log.debug("[rustfs] operation success operation={} objectKey={} attempt={}/{} durationMs={} bytes={}",
operation, objectKey, attempt, maxRetries, elapsedMillis(startedAt), bytes);
return result;
} catch (Exception ex) {
last = ex;
evictIdleConnections();
recordOperation(operation, attempt < maxRetries ? "retry" : "failure", elapsedNanos(startedAt));
recordFailure(operation, objectKey, ex);
if (attempt < maxRetries) {
long delayMillis = retryDelayMillis(attempt);
log.warn("[rustfs] operation failed, retrying operation={} objectKey={} attempt={}/{} delayMs={} err={}",
operation, objectKey, attempt, maxRetries, delayMillis, ex.getMessage());
sleepQuietly(delayMillis, "retrying rustfs " + operation);
}
}
}
} finally {
semaphore.release();
}
throw new IllegalStateException("failed to " + operation + " payload in transient storage", last);
}
private void acquirePermit(String operation, String objectKey, Semaphore semaphore) {
try {
boolean acquired;
long timeoutMillis = Math.max(0L, properties.getAcquirePermitTimeoutMillis());
if (timeoutMillis == 0L) {
acquired = semaphore.tryAcquire();
} else {
acquired = semaphore.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS);
}
if (!acquired) {
recordOperation(operation, "rejected", 0L);
throw new IllegalStateException("rustfs " + operation + " concurrency limit reached: " + objectKey);
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
recordOperation(operation, "rejected", 0L);
throw new IllegalStateException("interrupted while acquiring rustfs " + operation + " permit", ex);
}
}
private MinioClient buildClient() {
if (minioClientSupplier != null) {
return minioClientSupplier.get();
}
return MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
@@ -112,7 +215,7 @@ public class RustfsObjectStorageService {
.build();
}
private OkHttpClient getHttpClient() {
OkHttpClient getHttpClient() {
OkHttpClient current = httpClient;
if (current != null) {
return current;
@@ -123,13 +226,26 @@ public class RustfsObjectStorageService {
.connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS)
.readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS)
.writeTimeout(Math.max(1, properties.getWriteTimeoutSeconds()), TimeUnit.SECONDS)
.connectionPool(new ConnectionPool(
Math.max(0, properties.getConnectionPoolMaxIdle()),
Math.max(1L, properties.getConnectionPoolKeepAliveMillis()),
TimeUnit.MILLISECONDS))
.retryOnConnectionFailure(true)
.build();
}
return httpClient;
}
}
private void evictIdleConnections() {
OkHttpClient current = httpClient;
if (current != null) {
current.connectionPool().evictAll();
}
}
private void verifyObjectVisible(String objectKey) {
long startedAt = System.nanoTime();
RuntimeException last = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
@@ -137,11 +253,14 @@ public class RustfsObjectStorageService {
.bucket(properties.getBucket())
.object(objectKey)
.build());
recordOperation(OP_STAT, "success", elapsedNanos(startedAt));
return;
} catch (Exception ex) {
last = new IllegalStateException("transient payload is not visible after upload: " + objectKey, ex);
evictIdleConnections();
recordOperation(OP_STAT, attempt < 3 ? "retry" : "failure", elapsedNanos(startedAt));
if (attempt < 3) {
sleepQuietly(100L * attempt);
sleepQuietly(100L * attempt, "verifying transient payload upload");
}
}
}
@@ -150,16 +269,117 @@ public class RustfsObjectStorageService {
: last;
}
private void sleepQuietly(long millis) {
private void rejectIfCircuitOpen(String operation, String objectKey) {
long openUntil = circuitOpenUntilMillis;
if (openUntil <= 0L) {
return;
}
long now = System.currentTimeMillis();
if (now < openUntil) {
recordOperation(operation, "rejected", 0L);
throw new IllegalStateException("rustfs failure cooldown active operation=" + operation + " objectKey=" + objectKey);
}
}
private void recordFailure(String operation, String objectKey, Exception ex) {
long now = System.currentTimeMillis();
long windowMillis = Math.max(1L, properties.getFailureWindowSeconds()) * 1000L;
if (failureWindowStartedAtMillis <= 0L || now - failureWindowStartedAtMillis > windowMillis) {
failureWindowStartedAtMillis = now;
windowFailureCount.set(0);
}
int failures = windowFailureCount.incrementAndGet();
int threshold = Math.max(1, properties.getFailureWindowThreshold());
if ((OP_UPLOAD.equals(operation) || OP_READ.equals(operation)) && failures >= threshold) {
long cooldownMillis = Math.max(0L, properties.getFailureCooldownMillis());
circuitOpenUntilMillis = now + cooldownMillis;
windowFailureCount.set(0);
failureWindowStartedAtMillis = now;
log.error("[rustfs] failure window threshold reached, cooldown enabled operation={} objectKey={} failures={} threshold={} cooldownMs={} err={}",
operation, objectKey, failures, threshold, cooldownMillis, ex.getMessage());
}
}
private void resetFailureWindow() {
windowFailureCount.set(0);
failureWindowStartedAtMillis = 0L;
circuitOpenUntilMillis = 0L;
}
private long retryDelayMillis(int attempt) {
long baseDelay = Math.max(0L, properties.getBaseRetryDelayMillis());
long maxDelay = Math.max(baseDelay, properties.getMaxRetryDelayMillis());
long delay = Math.min(maxDelay, baseDelay * Math.max(1, attempt));
long jitter = Math.max(0L, properties.getRetryJitterMillis());
if (jitter > 0L) {
delay += ThreadLocalRandom.current().nextLong(jitter + 1L);
}
return delay;
}
private void sleepQuietly(long millis, String reason) {
if (millis <= 0L) {
return;
}
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while verifying transient payload upload", ex);
throw new IllegalStateException("interrupted while " + reason, ex);
}
}
private void recordPayloadBytes(long bytes) {
MeterRegistry registry = meterRegistry();
if (registry == null || bytes < 0L) {
return;
}
DistributionSummary.builder("aiimage.rustfs.payload.bytes")
.baseUnit("bytes")
.register(registry)
.record(bytes);
}
private void recordOperation(String operation, String result, long durationNanos) {
MeterRegistry registry = meterRegistry();
if (registry == null) {
return;
}
registry.counter("aiimage.rustfs.operation.total", "operation", operation, "result", result).increment();
if (durationNanos > 0L) {
Timer.builder("aiimage.rustfs.operation.duration")
.tag("operation", operation)
.tag("result", result)
.register(registry)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
}
public void recordLocalFallback() {
MeterRegistry registry = meterRegistry();
if (registry != null) {
registry.counter("aiimage.rustfs.fallback.local.total").increment();
}
}
private MeterRegistry meterRegistry() {
return meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable();
}
private long elapsedNanos(long startedAt) {
return System.nanoTime() - startedAt;
}
private long elapsedMillis(long startedAt) {
return TimeUnit.NANOSECONDS.toMillis(elapsedNanos(startedAt));
}
private boolean notBlank(String value) {
return value != null && !value.isBlank();
}
@FunctionalInterface
private interface CheckedSupplier<T> {
T get() throws Exception;
}
}

View File

@@ -1200,5 +1200,3 @@ public class QueryAsinTaskService {
}
}
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
@@ -54,6 +55,7 @@ public class SimilarAsinImagePrefetchService {
private final SimilarAsinImageEmbedder imageEmbedder;
private final TaskImageCacheMapper taskImageCacheMapper;
private final SimilarAsinProperties properties;
/**
* 每个 task 当前 in-flight 的预热 future。enqueue 时如果上一个还没完成,会先等它结束,
@@ -75,6 +77,9 @@ public class SimilarAsinImagePrefetchService {
* 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。
*/
public void enqueue(Long taskId, List<String> urls) {
if (!properties.isImageDbCacheEnabled()) {
return;
}
if (taskId == null || urls == null || urls.isEmpty()) {
return;
}
@@ -165,6 +170,9 @@ public class SimilarAsinImagePrefetchService {
* 失败/未命中返回 null由调用方走回退路径。
*/
public byte[] lookup(String url) {
if (!properties.isImageDbCacheEnabled()) {
return null;
}
if (url == null) {
return null;
}

View File

@@ -30,6 +30,15 @@ public interface TaskImageCacheMapper extends BaseMapper<TaskImageCacheEntity> {
@Select("SELECT image_bytes FROM biz_task_image_cache WHERE url_hash = #{urlHash} LIMIT 1")
byte[] selectBytesByUrlHash(@Param("urlHash") String urlHash);
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM biz_task_image_cache")
Long sumByteSize();
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM (SELECT byte_size FROM biz_task_image_cache ORDER BY last_used_at LIMIT #{limit}) oldest")
Long sumOldestBatchByteSize(@Param("limit") int limit);
@Delete("DELETE FROM biz_task_image_cache WHERE last_used_at < #{cutoff} ORDER BY last_used_at LIMIT #{limit}")
int deleteExpiredBatch(@Param("cutoff") LocalDateTime cutoff, @Param("limit") int limit);
@Delete("DELETE FROM biz_task_image_cache ORDER BY last_used_at LIMIT #{limit}")
int deleteOldestBatch(@Param("limit") int limit);
}

View File

@@ -39,18 +39,18 @@ public class TaskImageCacheCleanupService {
int maxBatches = Math.max(1, cleanupProperties.getMaxBatchesPerRun());
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
int deletedTotal = 0;
int batches = 0;
while (batches < maxBatches) {
int expiredDeletedTotal = 0;
int expiredBatches = 0;
while (expiredBatches < maxBatches) {
int deleted = taskImageCacheMapper.deleteExpiredBatch(cutoff, batchSize);
if (deleted <= 0) {
break;
}
deletedTotal += deleted;
batches++;
expiredDeletedTotal += deleted;
expiredBatches++;
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
log.warn("[task-image-cache-cleanup] stop because lock renew failed deletedTotal={} batches={}",
deletedTotal, batches);
expiredDeletedTotal, expiredBatches);
break;
}
if (deleted < batchSize) {
@@ -58,12 +58,49 @@ public class TaskImageCacheCleanupService {
}
}
if (deletedTotal > 0 || batches >= maxBatches) {
log.info("[task-image-cache-cleanup] finished cutoff={} retentionDays={} deletedTotal={} batches={} batchSize={} maxBatches={}",
cutoff, retentionDays, deletedTotal, batches, batchSize, maxBatches);
long maxBytes = cleanupProperties.getMaxBytes();
long targetBytes = cleanupProperties.getTargetBytes();
int capacityDeletedTotal = 0;
int capacityBatches = 0;
if (maxBytes > 0L) {
long actualTargetBytes = targetBytes > 0L && targetBytes < maxBytes ? targetBytes : maxBytes / 2L;
long currentBytes = nullToZero(taskImageCacheMapper.sumByteSize());
boolean overCapacity = currentBytes > maxBytes;
while (overCapacity && currentBytes > actualTargetBytes && capacityBatches < maxBatches) {
long batchBytes = nullToZero(taskImageCacheMapper.sumOldestBatchByteSize(batchSize));
if (batchBytes <= 0L) {
break;
}
int deleted = taskImageCacheMapper.deleteOldestBatch(batchSize);
if (deleted <= 0) {
break;
}
capacityDeletedTotal += deleted;
capacityBatches++;
currentBytes = Math.max(0L, currentBytes - batchBytes);
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
log.warn("[task-image-cache-cleanup] stop capacity cleanup because lock renew failed deletedTotal={} batches={}",
capacityDeletedTotal, capacityBatches);
break;
}
if (deleted < batchSize) {
break;
}
}
}
if (expiredDeletedTotal > 0 || expiredBatches >= maxBatches
|| capacityDeletedTotal > 0 || capacityBatches >= maxBatches) {
log.info("[task-image-cache-cleanup] finished cutoff={} retentionDays={} expiredDeleted={} expiredBatches={} capacityDeleted={} capacityBatches={} batchSize={} maxBatches={} maxBytes={} targetBytes={}",
cutoff, retentionDays, expiredDeletedTotal, expiredBatches, capacityDeletedTotal,
capacityBatches, batchSize, maxBatches, maxBytes, targetBytes);
}
} catch (Exception ex) {
log.warn("[task-image-cache-cleanup] failed msg={}", ex.getMessage(), ex);
}
}
private static long nullToZero(Long value) {
return value == null ? 0L : value;
}
}

View File

@@ -312,20 +312,39 @@ public class TransientPayloadStorageService {
return content;
}
String objectKey = buildObjectKey(category, moduleType, taskId, scopeHash, entryKey);
long rawBytes = payloadBytes(content);
if (isPositiveLimit(properties.getWarnPayloadBytes()) && rawBytes > properties.getWarnPayloadBytes()) {
log.warn("[transient-payload] payload size exceeds warn threshold category={} moduleType={} taskId={} objectKey={} rawBytes={} warnBytes={}",
category, moduleType, taskId, objectKey, rawBytes, properties.getWarnPayloadBytes());
}
boolean rawOversize = isPositiveLimit(properties.getMaxPayloadBytes()) && rawBytes > properties.getMaxPayloadBytes();
String storedContent = encodeStoredPayload(content);
long storedBytes = payloadBytes(storedContent);
boolean storedOversize = isPositiveLimit(properties.getMaxStoredPayloadBytes()) && storedBytes > properties.getMaxStoredPayloadBytes();
if (rawOversize || storedOversize) {
log.warn("[transient-payload] payload size exceeds rustfs limit category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} maxRawBytes={} maxStoredBytes={} fallbackToLocal={}",
category, moduleType, taskId, objectKey, rawBytes, storedBytes,
properties.getMaxPayloadBytes(), properties.getMaxStoredPayloadBytes(), properties.isFallbackToLocalOnOversize());
if (!properties.isFallbackToLocalOnOversize()) {
throw new IllegalStateException("transient payload exceeds configured size limit: " + objectKey);
}
}
String pointer = null;
boolean rustfsFallbackToLocal = false;
if (rustfsObjectStorageService.isConfigured()) {
boolean rustfsFallbackToLocal = rawOversize || storedOversize;
if (!rustfsFallbackToLocal && rustfsObjectStorageService.isConfigured()) {
try {
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
} catch (Exception ex) {
rustfsFallbackToLocal = true;
// 升级为 ERRORrustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} taskId={} objectKey={} err={}",
instanceMetadata.getInstanceId(), category, taskId, objectKey, ex.getMessage());
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} err={}",
instanceMetadata.getInstanceId(), category, moduleType, taskId, objectKey, rawBytes, storedBytes, ex.getMessage());
}
}
if (pointer == null) {
if (rustfsFallbackToLocal) {
rustfsObjectStorageService.recordLocalFallback();
}
pointer = storeLocal(objectKey, storedContent);
}
// P0-3记录本次 store 是否走 local 兜底,供调用方在拿到 pointer 后立即查询。
@@ -429,6 +448,14 @@ public class TransientPayloadStorageService {
}
}
private long payloadBytes(String content) {
return Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8).length;
}
private boolean isPositiveLimit(long value) {
return value > 0L;
}
private String decodeStoredPayload(String storedContent) {
if (storedContent == null || storedContent.isBlank()) {
return storedContent;

View File

@@ -19,11 +19,37 @@ AIIMAGE_OSS_ACCESS_KEY_ID=change-me
AIIMAGE_OSS_ACCESS_KEY_SECRET=change-me
AIIMAGE_TRANSIENT_STORAGE_ENABLED=true
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://change-me
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://121.196.149.225:9000
AIIMAGE_TRANSIENT_STORAGE_BUCKET=change-me
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID=change-me
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET=change-me
AIIMAGE_TRANSIENT_STORAGE_REGION=us-east-1
AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS=10
AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS=60
AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS=60
AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES=3
AIIMAGE_TRANSIENT_STORAGE_READ_MAX_RETRIES=3
AIIMAGE_TRANSIENT_STORAGE_DELETE_MAX_RETRIES=3
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_UPLOADS=16
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_READS=32
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_DELETES=8
AIIMAGE_TRANSIENT_STORAGE_ACQUIRE_PERMIT_TIMEOUT_MILLIS=2000
AIIMAGE_TRANSIENT_STORAGE_BASE_RETRY_DELAY_MILLIS=500
AIIMAGE_TRANSIENT_STORAGE_MAX_RETRY_DELAY_MILLIS=5000
AIIMAGE_TRANSIENT_STORAGE_RETRY_JITTER_MILLIS=250
AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_SECONDS=60
AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_THRESHOLD=20
AIIMAGE_TRANSIENT_STORAGE_FAILURE_COOLDOWN_MILLIS=10000
AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_MAX_IDLE=0
AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_KEEP_ALIVE_MILLIS=1
AIIMAGE_TRANSIENT_STORAGE_WARN_PAYLOAD_BYTES=5242880
AIIMAGE_TRANSIENT_STORAGE_MAX_PAYLOAD_BYTES=52428800
AIIMAGE_TRANSIENT_STORAGE_MAX_STORED_PAYLOAD_BYTES=52428800
AIIMAGE_TRANSIENT_STORAGE_FALLBACK_TO_LOCAL_ON_OVERSIZE=true
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_ENABLED=true
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_CRON=0 */5 * * * *
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_QUEUE_CAPACITY=10000
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_BATCH_SIZE=200
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp

View File

@@ -100,6 +100,28 @@ aiimage:
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60}
upload-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES:3}
read-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_READ_MAX_RETRIES:3}
delete-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_MAX_RETRIES:3}
max-concurrent-uploads: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_UPLOADS:16}
max-concurrent-reads: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_READS:32}
max-concurrent-deletes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_DELETES:8}
acquire-permit-timeout-millis: ${AIIMAGE_TRANSIENT_STORAGE_ACQUIRE_PERMIT_TIMEOUT_MILLIS:2000}
base-retry-delay-millis: ${AIIMAGE_TRANSIENT_STORAGE_BASE_RETRY_DELAY_MILLIS:500}
max-retry-delay-millis: ${AIIMAGE_TRANSIENT_STORAGE_MAX_RETRY_DELAY_MILLIS:5000}
retry-jitter-millis: ${AIIMAGE_TRANSIENT_STORAGE_RETRY_JITTER_MILLIS:250}
failure-window-seconds: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_SECONDS:60}
failure-window-threshold: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_THRESHOLD:20}
failure-cooldown-millis: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_COOLDOWN_MILLIS:10000}
connection-pool-max-idle: ${AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_MAX_IDLE:0}
connection-pool-keep-alive-millis: ${AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_KEEP_ALIVE_MILLIS:1}
warn-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_WARN_PAYLOAD_BYTES:5242880}
max-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_PAYLOAD_BYTES:52428800}
max-stored-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_STORED_PAYLOAD_BYTES:52428800}
fallback-to-local-on-oversize: ${AIIMAGE_TRANSIENT_STORAGE_FALLBACK_TO_LOCAL_ON_OVERSIZE:true}
delete-retry-enabled: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_ENABLED:true}
delete-retry-cron: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_CRON:0 */5 * * * *}
delete-retry-queue-capacity: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_QUEUE_CAPACITY:10000}
delete-retry-batch-size: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_BATCH_SIZE:200}
storage:
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
@@ -142,6 +164,8 @@ aiimage:
retention-days: ${AIIMAGE_TASK_IMAGE_CACHE_RETENTION_DAYS:3}
batch-size: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_BATCH_SIZE:5000}
max-batches-per-run: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_MAX_BATCHES_PER_RUN:200}
max-bytes: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_MAX_BYTES:2147483648}
target-bytes: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_TARGET_BYTES:1073741824}
result-file-job:
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
@@ -197,6 +221,7 @@ aiimage:
image-download-pool-size: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_POOL_SIZE:32}
image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:5}
image-cache-max-bytes: ${AIIMAGE_SIMILAR_ASIN_IMAGE_CACHE_MAX_BYTES:268435456}
image-db-cache-enabled: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DB_CACHE_ENABLED:false}
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30}
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}

View File

@@ -25,6 +25,24 @@ class AppearancePatentCozeClientTest {
new BrandCheckClient(new BrandCheckProperties())
);
@Test
void markRowsFailedLeavesUserFacingResultBlankWhenAsyncPollTimeout() {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
List<AppearancePatentResultRowDto> failedRows =
client.markRowsFailed(List.of(row), "Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
assertThat(failedRows).hasSize(1);
AppearancePatentResultRowDto failed = failedRows.get(0);
assertThat(failed.getError()).isEqualTo("Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
assertThat(failed.getStatus()).isEqualTo("FAILED");
assertThat(failed.getTitleRisk()).isNull();
assertThat(failed.getAppearanceRisk()).isNull();
assertThat(failed.getPatentRisk()).isNull();
assertThat(failed.getConclusion()).isNull();
}
@Test
void titleRiskIsInfringementWhenBrandCheckHasFailedData() {
BrandCheckClient.BrandCheckBatchResult result =

View File

@@ -0,0 +1,40 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class RustfsDeleteRetryServiceTest {
@Test
void retryPendingDeletesRefillsDeferredItemsBeforeQueueDrainsCompletely() {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setDeleteRetryEnabled(true);
properties.setDeleteRetryQueueCapacity(2);
properties.setDeleteRetryBatchSize(1);
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
doAnswer(invocation -> {
String objectKey = invocation.getArgument(0);
if ("b".equals(objectKey)) {
throw new IllegalStateException("still failing");
}
return null;
}).when(rustfs).deleteObjectFromRetry(org.mockito.ArgumentMatchers.anyString());
RustfsDeleteRetryService service = new RustfsDeleteRetryService(properties, rustfs);
service.enqueue("a", new IllegalStateException("failed-a"));
service.enqueue("b", new IllegalStateException("failed-b"));
service.enqueue("c", new IllegalStateException("failed-c"));
service.retryPendingDeletes();
service.retryPendingDeletes();
service.retryPendingDeletes();
verify(rustfs).deleteObjectFromRetry("c");
assertEquals(1, service.pendingCount());
}
}

View File

@@ -0,0 +1,138 @@
package com.nanri.aiimage.modules.file.service.object;
import com.nanri.aiimage.config.TransientStorageProperties;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.MinioClient;
import okhttp3.OkHttpClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import java.lang.reflect.Field;
import java.util.concurrent.Semaphore;
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.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RustfsObjectStorageServiceTest {
@Test
void httpClientUsesConfiguredConnectionPoolAndKeepsRetryEnabled() {
TransientStorageProperties properties = configuredProperties();
properties.setConnectionPoolMaxIdle(3);
properties.setConnectionPoolKeepAliveMillis(1500);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, provider(new SimpleMeterRegistry()), emptyProvider(), null);
OkHttpClient client = service.getHttpClient();
assertTrue(client.retryOnConnectionFailure(), "RustFS OkHttp 应开启连接失败重试");
assertNotNull(client.connectionPool(), "RustFS OkHttp 应使用显式 connectionPool 配置");
}
@Test
void deleteFailureRetriesAndEnqueuesCompensation() {
TransientStorageProperties properties = configuredProperties();
properties.setDeleteMaxRetries(2);
properties.setBaseRetryDelayMillis(0);
properties.setRetryJitterMillis(0);
RustfsDeleteRetryService retryService = mock(RustfsDeleteRetryService.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), provider(retryService), () -> {
throw new IllegalStateException("rustfs down");
});
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.deleteObject("task/a.json"));
assertTrue(ex.getMessage().contains("delete"));
verify(retryService).enqueue(any(String.class), any(Throwable.class));
}
@Test
void deleteFromRetryDoesNotEnqueueAgain() {
TransientStorageProperties properties = configuredProperties();
properties.setDeleteMaxRetries(1);
RustfsDeleteRetryService retryService = mock(RustfsDeleteRetryService.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), provider(retryService), () -> {
throw new IllegalStateException("rustfs down");
});
assertThrows(IllegalStateException.class, () -> service.deleteObjectFromRetry("task/a.json"));
verify(retryService, never()).enqueue(any(String.class), any(Throwable.class));
}
@Test
void readPermitTimeoutRejectsImmediately() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setAcquirePermitTimeoutMillis(0);
properties.setMaxConcurrentReads(1);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), emptyProvider(), () -> mock(MinioClient.class));
Semaphore readSemaphore = privateSemaphore(service, "readSemaphore");
assertTrue(readSemaphore.tryAcquire());
try {
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.readObjectAsString("task/a.json"));
assertTrue(ex.getMessage().contains("concurrency limit"));
} finally {
readSemaphore.release();
}
}
@Test
void failureWindowOpensCooldownForUpload() {
TransientStorageProperties properties = configuredProperties();
properties.setUploadMaxRetries(1);
properties.setFailureWindowThreshold(1);
properties.setFailureCooldownMillis(10000);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, emptyProvider(), emptyProvider(), () -> {
throw new IllegalStateException("rustfs down");
});
assertThrows(IllegalStateException.class, () -> service.uploadText("task/a.json", "{}", false));
IllegalStateException rejected = assertThrows(IllegalStateException.class,
() -> service.uploadText("task/b.json", "{}", false));
assertTrue(rejected.getMessage().contains("cooldown active"));
}
private static TransientStorageProperties configuredProperties() {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setEndpoint("http://127.0.0.1:9000");
properties.setBucket("bucket");
properties.setAccessKeyId("ak");
properties.setAccessKeySecret("sk");
properties.setBaseRetryDelayMillis(0);
properties.setRetryJitterMillis(0);
return properties;
}
@SuppressWarnings("unchecked")
private static Semaphore privateSemaphore(RustfsObjectStorageService service, String fieldName) throws Exception {
Field field = RustfsObjectStorageService.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (Semaphore) field.get(service);
}
private static <T> ObjectProvider<T> provider(T value) {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(value);
return provider;
}
private static <T> ObjectProvider<T> emptyProvider() {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(null);
return provider;
}
}

View File

@@ -0,0 +1,113 @@
package com.nanri.aiimage.modules.task.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TransientPayloadStorageServiceTest {
@TempDir
Path tempDir;
@Test
void oversizeRawPayloadFallsBackToLocalAndKeepsFallbackMarker() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(4, 1024, true));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "abcdef", false);
assertTrue(pointer.startsWith("local:i/test-instance/task-parsed/test/1/scope/latest.json"));
assertTrue(service.wasLastStoreLocalFallback());
assertEquals("abcdef", service.resolvePayload(pointer, "read failed"));
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
verify(rustfs).recordLocalFallback();
}
@Test
void encodedPayloadLimitCanForceLocalFallback() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(1024, 1, true));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "a", false);
assertTrue(pointer.startsWith("local:"));
assertTrue(service.wasLastStoreLocalFallback());
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
}
@Test
void oversizePayloadCanBeRejectedByConfiguration() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(4, 1024, false));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> service.storeParsedPayloadFast("TEST", 1L, "scope", "abcdef", false));
assertTrue(ex.getMessage().contains("exceeds configured size limit"));
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
}
@Test
void smallPayloadStillUsesRustfsAndClearsFallbackMarker() {
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
when(rustfs.isConfigured()).thenReturn(true);
when(rustfs.uploadText(anyString(), anyString(), anyBoolean())).thenAnswer(invocation -> invocation.getArgument(0));
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(1024, 4096, true));
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "abc", false);
assertTrue(pointer.startsWith("rustfs:task-parsed/test/1/scope/latest.json"));
assertFalse(service.wasLastStoreLocalFallback());
verify(rustfs).uploadText(anyString(), anyString(), anyBoolean());
}
private TransientPayloadStorageService newService(RustfsObjectStorageService rustfs,
TransientStorageProperties transientProperties) {
StorageProperties storageProperties = new StorageProperties();
storageProperties.setLocalTempDir(tempDir.toString());
return new TransientPayloadStorageService(
transientProperties,
storageProperties,
rustfs,
mock(OssStorageService.class),
new ObjectMapper(),
new InstanceMetadata("test-instance"),
mock(TaskChunkMapper.class),
mock(TaskScopeStateMapper.class));
}
private TransientStorageProperties propertiesWithLimit(long maxPayloadBytes,
long maxStoredPayloadBytes,
boolean fallbackToLocalOnOversize) {
TransientStorageProperties properties = new TransientStorageProperties();
properties.setEnabled(true);
properties.setMaxPayloadBytes(maxPayloadBytes);
properties.setMaxStoredPayloadBytes(maxStoredPayloadBytes);
properties.setWarnPayloadBytes(1);
properties.setFallbackToLocalOnOversize(fallbackToLocalOnOversize);
return properties;
}
}