task-66: 限制 RustFS 并发读写与重试的总资源预算
- 新增 aiimage.transient-storage.max-total-concurrent-operations 配置 (默认 0 不启用):跨 upload/read/delete 的总并发预算 - executeWithRetry 入口获取一个总许可,整次操作(含全部重试)全程占用, 重试不额外消耗,超预算立即拒绝并计入 operation=total rejected 指标 - 失败/完成后 finally 释放总许可,无残留锁;负值/0 视为未启用保持原行为
This commit is contained in:
@@ -23,6 +23,11 @@ public class TransientStorageProperties {
|
||||
private int maxConcurrentUploads = 16;
|
||||
private int maxConcurrentReads = 32;
|
||||
private int maxConcurrentDeletes = 8;
|
||||
/**
|
||||
* 跨操作类型的总并发预算(含重试期间):一次操作全程占用一个总许可,
|
||||
* 防止多任务叠加时读写与重试之和突破对后端的总压力上限。0/负值表示不启用。
|
||||
*/
|
||||
private long maxTotalConcurrentOperations = 0;
|
||||
private long acquirePermitTimeoutMillis = 2000;
|
||||
private long baseRetryDelayMillis = 500;
|
||||
private long maxRetryDelayMillis = 5000;
|
||||
|
||||
+54
-33
@@ -35,6 +35,7 @@ public class RustfsObjectStorageService {
|
||||
private static final String OP_READ = "read";
|
||||
private static final String OP_DELETE = "delete";
|
||||
private static final String OP_STAT = "stat";
|
||||
private static final String OP_TOTAL = "total";
|
||||
|
||||
private final TransientStorageProperties properties;
|
||||
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
|
||||
@@ -43,6 +44,7 @@ public class RustfsObjectStorageService {
|
||||
private final Semaphore uploadSemaphore;
|
||||
private final Semaphore readSemaphore;
|
||||
private final Semaphore deleteSemaphore;
|
||||
private final Semaphore totalSemaphore;
|
||||
private final AtomicInteger windowFailureCount = new AtomicInteger();
|
||||
private volatile long failureWindowStartedAtMillis;
|
||||
private volatile long circuitOpenUntilMillis;
|
||||
@@ -66,6 +68,13 @@ public class RustfsObjectStorageService {
|
||||
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()));
|
||||
// 总预算为 0/负值时不启用(Semaphore(0) 的 tryAcquire 永远失败,须用启用开关区分)
|
||||
long totalBudget = Math.max(0L, properties.getMaxTotalConcurrentOperations());
|
||||
this.totalSemaphore = new Semaphore((int) Math.min(totalBudget, Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
private boolean isTotalBudgetEnabled() {
|
||||
return properties.getMaxTotalConcurrentOperations() > 0L;
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
@@ -185,45 +194,57 @@ public class RustfsObjectStorageService {
|
||||
CheckedSupplier<T> supplier) {
|
||||
long startedAt = System.nanoTime();
|
||||
Exception last = null;
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
checkDeadline(operation, objectKey, deadlineNanos);
|
||||
rejectIfCircuitOpen(operation, objectKey);
|
||||
acquirePermit(operation, objectKey, semaphore, deadlineNanos);
|
||||
long delayMillis = 0L;
|
||||
try {
|
||||
// 总资源预算:一次操作(含全部重试)全程占用一个总许可,重试不额外消耗。
|
||||
boolean totalAcquired = false;
|
||||
if (isTotalBudgetEnabled()) {
|
||||
acquirePermit(OP_TOTAL, objectKey, totalSemaphore, deadlineNanos);
|
||||
totalAcquired = true;
|
||||
}
|
||||
try {
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
checkDeadline(operation, objectKey, deadlineNanos);
|
||||
rejectIfCircuitOpen(operation, objectKey);
|
||||
acquirePermit(operation, objectKey, semaphore, deadlineNanos);
|
||||
long delayMillis = 0L;
|
||||
try {
|
||||
checkDeadline(operation, objectKey, deadlineNanos);
|
||||
T result = supplier.get();
|
||||
checkDeadline(operation, objectKey, deadlineNanos);
|
||||
if (!OP_UPLOAD.equals(operation)) {
|
||||
resetFailureWindow(operation);
|
||||
}
|
||||
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;
|
||||
recordOperation(operation, attempt < maxRetries ? "retry" : "failure", elapsedNanos(startedAt));
|
||||
recordFailure(operation, objectKey, ex);
|
||||
if (attempt < maxRetries) {
|
||||
delayMillis = retryDelayMillis(attempt);
|
||||
log.warn("[rustfs] operation failed, retrying operation={} objectKey={} attempt={}/{} delayMs={} err={}",
|
||||
operation, objectKey, attempt, maxRetries, delayMillis, ex.getMessage());
|
||||
try {
|
||||
checkDeadline(operation, objectKey, deadlineNanos);
|
||||
T result = supplier.get();
|
||||
checkDeadline(operation, objectKey, deadlineNanos);
|
||||
if (!OP_UPLOAD.equals(operation)) {
|
||||
resetFailureWindow(operation);
|
||||
}
|
||||
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;
|
||||
recordOperation(operation, attempt < maxRetries ? "retry" : "failure", elapsedNanos(startedAt));
|
||||
recordFailure(operation, objectKey, ex);
|
||||
if (attempt < maxRetries) {
|
||||
delayMillis = retryDelayMillis(attempt);
|
||||
log.warn("[rustfs] operation failed, retrying operation={} objectKey={} attempt={}/{} delayMs={} err={}",
|
||||
operation, objectKey, attempt, maxRetries, delayMillis, ex.getMessage());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
if (last instanceof OperationTimeoutException timeoutException) {
|
||||
throw timeoutException;
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
sleepQuietly(delayMillis, "retrying rustfs " + operation,
|
||||
operation, objectKey, deadlineNanos);
|
||||
}
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
if (last instanceof OperationTimeoutException timeoutException) {
|
||||
throw timeoutException;
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
sleepQuietly(delayMillis, "retrying rustfs " + operation,
|
||||
operation, objectKey, deadlineNanos);
|
||||
throw new IllegalStateException("failed to " + operation + " payload in transient storage", last);
|
||||
} finally {
|
||||
if (totalAcquired) {
|
||||
totalSemaphore.release();
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("failed to " + operation + " payload in transient storage", last);
|
||||
}
|
||||
|
||||
private void acquirePermit(String operation, String objectKey, Semaphore semaphore, long deadlineNanos) {
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
package com.nanri.aiimage.modules.file.service.object;
|
||||
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.GetObjectResponse;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.StatObjectArgs;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 66:限制 RustFS 并发读写与重试的总资源预算,防止多任务叠加爆发。
|
||||
* RustfsObjectStorageService 在分类信号量(upload/read/delete)之上增加总并发预算
|
||||
* maxTotalConcurrentOperations:单次操作(含重试)全程占用一个总许可,
|
||||
* 超预算立即拒绝(rejected 指标),失败/完成后释放,避免多任务叠加时
|
||||
* 读写与重试叠加突破对后端的总压力上限。
|
||||
*/
|
||||
class RustfsTotalBudgetTest {
|
||||
|
||||
private TransientStorageProperties properties;
|
||||
private SimpleMeterRegistry simpleRegistry;
|
||||
private MinioClient client;
|
||||
private ObjectProvider<MeterRegistry> meterRegistryProvider;
|
||||
private ObjectProvider<RustfsDeleteRetryService> deleteRetryProvider;
|
||||
private RustfsObjectStorageService service;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
properties = new TransientStorageProperties();
|
||||
simpleRegistry = new SimpleMeterRegistry();
|
||||
meterRegistryProvider = mock(ObjectProvider.class);
|
||||
when(meterRegistryProvider.getIfAvailable()).thenReturn(simpleRegistry);
|
||||
deleteRetryProvider = mock(ObjectProvider.class);
|
||||
client = mock(MinioClient.class);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
simpleRegistry.clear();
|
||||
}
|
||||
|
||||
private static void setConfigured(TransientStorageProperties properties) {
|
||||
properties.setEndpoint("http://rustfs.local:9000");
|
||||
properties.setBucket("bucket");
|
||||
properties.setAccessKeyId("ak");
|
||||
properties.setAccessKeySecret("sk");
|
||||
properties.setRegion("us-east-1");
|
||||
}
|
||||
|
||||
private void stubUploadSuccess() {
|
||||
try {
|
||||
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
|
||||
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
|
||||
when(client.statObject(ArgumentMatchers.any(StatObjectArgs.class))).thenReturn(null);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void stubReadSuccess(String content) throws Exception {
|
||||
GetObjectResponse response = mock(GetObjectResponse.class);
|
||||
when(response.readAllBytes()).thenReturn(content.getBytes(StandardCharsets.UTF_8));
|
||||
when(client.getObject(ArgumentMatchers.any(GetObjectArgs.class))).thenReturn(response);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Semaphore totalSemaphore() {
|
||||
return (Semaphore) ReflectionTestUtils.getField(service, "totalSemaphore");
|
||||
}
|
||||
|
||||
private static long rejectedTotal(MeterRegistry registry) {
|
||||
Counter counter = registry.find("aiimage.rustfs.operation.total")
|
||||
.tags("operation", "total", "result", "rejected").counter();
|
||||
return counter == null ? 0L : (long) counter.count();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_normal_default_path() throws Exception {
|
||||
// 默认路径:配置总预算后正常上传成功,操作结束后总许可释放回池。
|
||||
setConfigured(properties);
|
||||
properties.setMaxTotalConcurrentOperations(8);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
stubUploadSuccess();
|
||||
|
||||
String objectKey = service.uploadText("task-parsed/test/1/scope/latest.json", "{\"a\":1}");
|
||||
|
||||
assertEquals("task-parsed/test/1/scope/latest.json", objectKey);
|
||||
assertEquals(8, totalSemaphore().availablePermits(), "上传完成后总许可全部释放");
|
||||
assertEquals(0L, rejectedTotal(simpleRegistry));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_normal_multiple_items() throws Exception {
|
||||
// 批量场景:多任务并发在总预算内全部成功,无拒绝无丢失。
|
||||
setConfigured(properties);
|
||||
properties.setMaxTotalConcurrentOperations(16);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
stubUploadSuccess();
|
||||
int concurrency = 4;
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger();
|
||||
Thread[] threads = new Thread[concurrency];
|
||||
for (int i = 0; i < concurrency; i++) {
|
||||
final int index = i;
|
||||
threads[i] = new Thread(() -> {
|
||||
try {
|
||||
start.await(2, TimeUnit.SECONDS);
|
||||
service.uploadText("task-parsed/test/" + index + "/scope/latest.json", "{\"i\":" + index + "}");
|
||||
successCount.incrementAndGet();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
threads[i].setDaemon(true);
|
||||
threads[i].start();
|
||||
}
|
||||
start.countDown();
|
||||
for (Thread thread : threads) {
|
||||
thread.join(5_000);
|
||||
}
|
||||
|
||||
assertEquals(concurrency, successCount.get(), "全部并发任务成功");
|
||||
verify(client, times(concurrency)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
|
||||
assertEquals(16, totalSemaphore().availablePermits());
|
||||
assertEquals(0L, rejectedTotal(simpleRegistry));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 幂等:总预算 1 时串行重复操作每次都能获取并释放许可,不产生重复拒绝。
|
||||
setConfigured(properties);
|
||||
properties.setMaxTotalConcurrentOperations(1);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
stubUploadSuccess();
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertEquals("task-parsed/test/1/scope/latest.json",
|
||||
service.uploadText("task-parsed/test/1/scope/latest.json", "{\"x\":1}"));
|
||||
}
|
||||
|
||||
verify(client, times(3)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
|
||||
assertEquals(1, totalSemaphore().availablePermits(), "每次操作后许可释放");
|
||||
assertEquals(0L, rejectedTotal(simpleRegistry));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_boundary_empty_input() throws Exception {
|
||||
// 空输入:总预算未配置(0)时保持原行为,不启用总量限制。
|
||||
setConfigured(properties);
|
||||
stubUploadSuccess();
|
||||
|
||||
assertEquals("k", service.uploadText("k", "v"));
|
||||
|
||||
verify(client, times(1)).putObject(ArgumentMatchers.any(PutObjectArgs.class));
|
||||
assertEquals(0L, rejectedTotal(simpleRegistry));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_boundary_single_item() throws Exception {
|
||||
// 单元素:总预算 1 时单次读取成功,许可释放,跨操作类型互不残留。
|
||||
setConfigured(properties);
|
||||
properties.setMaxTotalConcurrentOperations(1);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
stubReadSuccess("{}");
|
||||
|
||||
assertEquals("{}", service.readObjectAsString("task-parsed/test/1/scope/latest.json"));
|
||||
assertEquals(1, totalSemaphore().availablePermits());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_boundary_limit_and_overflow() throws Exception {
|
||||
// 上限/超限:总预算 1 时第二个并发操作(不同分类)立即被拒绝,
|
||||
// rejected 指标 +1,不发生无界排队;操作结束后许可释放。
|
||||
setConfigured(properties);
|
||||
properties.setMaxTotalConcurrentOperations(1);
|
||||
properties.setAcquirePermitTimeoutMillis(0);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
stubUploadSuccess();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
entered.countDown();
|
||||
Thread.sleep(500);
|
||||
throw new java.io.IOException("hold");
|
||||
}).when(client).putObject(ArgumentMatchers.any(PutObjectArgs.class));
|
||||
|
||||
Thread first = new Thread(() -> {
|
||||
try {
|
||||
service.uploadText("block", "v");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
first.setDaemon(true);
|
||||
first.start();
|
||||
try {
|
||||
assertTrue(entered.await(2, TimeUnit.SECONDS), "首线程已持有总预算");
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.readObjectAsString("blocked"),
|
||||
"跨操作类型并发超预算被拒绝");
|
||||
assertEquals(1L, rejectedTotal(simpleRegistry), "拒绝计入 total rejected 指标");
|
||||
first.interrupt();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_invalid_input_rejected() throws Exception {
|
||||
// 非法参数:总预算为负或 0 时视为未启用(构造不崩溃,行为与默认一致)。
|
||||
setConfigured(properties);
|
||||
properties.setMaxTotalConcurrentOperations(-5);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
stubUploadSuccess();
|
||||
|
||||
assertEquals("k", service.uploadText("k", "v"));
|
||||
assertEquals(0L, rejectedTotal(simpleRegistry));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_066_rustfs_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:上传失败后总许可释放(错误可恢复),恢复后操作成功且无残留锁。
|
||||
setConfigured(properties);
|
||||
properties.setMaxTotalConcurrentOperations(1);
|
||||
service = new RustfsObjectStorageService(properties, meterRegistryProvider, deleteRetryProvider,
|
||||
() -> client);
|
||||
try {
|
||||
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
|
||||
.thenThrow(new java.io.IOException("rustfs down"));
|
||||
assertThrows(Exception.class, () -> service.uploadText("task-parsed/test/1/scope/latest.json", "{\"a\":1}"));
|
||||
} finally {
|
||||
try {
|
||||
stubUploadSuccess();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
assertEquals(1, totalSemaphore().availablePermits(), "失败后总许可释放");
|
||||
|
||||
assertEquals("task-parsed/test/1/scope/latest.json",
|
||||
service.uploadText("task-parsed/test/1/scope/latest.json", "{\"b\":2}"));
|
||||
assertEquals(1, totalSemaphore().availablePermits());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user