新增内容,新增拉起软件层
This commit is contained in:
@@ -83,6 +83,7 @@ public class SimilarAsinProperties {
|
||||
* 出现淘汰过频影响命中率时可上调到 512MB;2GB 堆约束下不建议超过 768MB。
|
||||
*/
|
||||
private long imageCacheMaxBytes = 256L * 1024L * 1024L;
|
||||
private boolean imageDbCacheEnabled = false;
|
||||
|
||||
/**
|
||||
* 是否在 Coze 请求 parameters 中附带 api_key 字段。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1200,5 +1200,3 @@ public class QueryAsinTaskService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// 升级为 ERROR:rustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user