task-61: Rustfs 对象存储指标基线验收测试(计数/计时/分布/熔断/拒绝)
This commit is contained in:
+262
@@ -0,0 +1,262 @@
|
||||
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.DistributionSummary;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.minio.GetObjectResponse;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
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.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
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.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 61:共享任务链路资源指标基线:线程、连接、队列、GC、Redis、RustFS 和 DB。
|
||||
* RustfsObjectStorageService 为上传/读取/删除/stat 记录 Micrometer 指标
|
||||
* (aiimage.rustfs.operation.total 计数、duration 计时、payload.bytes 分布、
|
||||
* fallback.local.total 计数)与并发信号量上限、超时 deadline、失败窗口熔断。
|
||||
* 指标基线:未配置零指标、成功/重试/失败/拒绝各结果分类计数准确、重复操作
|
||||
* 计数精确累加、空输入零调用、单元素计数正确、并发超限 rejected、熔断后恢复。
|
||||
*/
|
||||
class RustfsMetricsBaselineTest {
|
||||
|
||||
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 static long counter(MeterRegistry registry, String name, String operation, String result) {
|
||||
Counter counter = registry.find(name).tags("operation", operation, "result", result).counter();
|
||||
return counter == null ? 0L : (long) counter.count();
|
||||
}
|
||||
|
||||
private GetObjectResponse readResponse(String content) throws Exception {
|
||||
GetObjectResponse response = mock(GetObjectResponse.class);
|
||||
when(response.readAllBytes()).thenReturn(content.getBytes(StandardCharsets.UTF_8));
|
||||
return response;
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_normal_default_path() {
|
||||
// 正常路径:未配置时零调用零指标(基线默认),调用抛可识别异常。
|
||||
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"),
|
||||
"未配置零指标");
|
||||
assertThrows(IllegalStateException.class, () -> service.uploadText("k", "v"),
|
||||
"未配置抛可识别异常");
|
||||
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"),
|
||||
"失败调用不产生 success 指标");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_normal_multiple_items() throws Exception {
|
||||
// 批量场景:多次操作各结果分类计数准确累加(retry + failure),无丢失无重复。
|
||||
setConfigured(properties);
|
||||
properties.setReadMaxRetries(2);
|
||||
properties.setUploadMaxRetries(2);
|
||||
properties.setDeleteMaxRetries(2);
|
||||
properties.setBaseRetryDelayMillis(0);
|
||||
properties.setMaxRetryDelayMillis(0);
|
||||
// 高失败窗口阈值:避免失败计数触发熔断,干扰各结果分类计数断言。
|
||||
properties.setFailureWindowThreshold(1000);
|
||||
|
||||
doThrow(new java.io.IOException("rustfs down")).when(client)
|
||||
.getObject(ArgumentMatchers.any(io.minio.GetObjectArgs.class));
|
||||
doThrow(new java.io.IOException("rustfs down")).when(client)
|
||||
.putObject(ArgumentMatchers.any(PutObjectArgs.class));
|
||||
doThrow(new java.io.IOException("rustfs down")).when(client)
|
||||
.removeObject(ArgumentMatchers.any(RemoveObjectArgs.class));
|
||||
|
||||
assertThrows(Exception.class, () -> service.uploadText("a", "v"));
|
||||
assertThrows(Exception.class, () -> service.readObjectAsString("b"));
|
||||
assertThrows(Exception.class, () -> service.deleteObject("c"));
|
||||
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "retry"));
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "retry"));
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "retry"));
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "failure"));
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "failure"));
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "failure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 幂等:同一操作重复执行成功 → 指标按次数精确累加,不重复注册不丢计数。
|
||||
setConfigured(properties);
|
||||
|
||||
GetObjectResponse readResponse = readResponse("{}");
|
||||
when(client.getObject(ArgumentMatchers.any(io.minio.GetObjectArgs.class)))
|
||||
.thenReturn(readResponse);
|
||||
when(client.statObject(ArgumentMatchers.any(StatObjectArgs.class)))
|
||||
.thenReturn(null);
|
||||
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
|
||||
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
|
||||
doNothing().when(client).removeObject(ArgumentMatchers.any(RemoveObjectArgs.class));
|
||||
|
||||
service.uploadText("k", "v");
|
||||
service.uploadText("k", "v");
|
||||
service.readObjectAsString("k");
|
||||
service.readObjectAsString("k");
|
||||
service.deleteObject("k");
|
||||
service.deleteObject("k");
|
||||
|
||||
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"));
|
||||
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "success"));
|
||||
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "success"));
|
||||
assertEquals(2L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "stat", "success"),
|
||||
"上传后 stat 可见性校验 2 次");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_boundary_empty_input() {
|
||||
// 空输入:空对象键删除零调用零指标,不创建无效资源。
|
||||
setConfigured(properties);
|
||||
service.deleteObject("");
|
||||
service.deleteObject(null);
|
||||
|
||||
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "delete", "success"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_boundary_single_item() throws Exception {
|
||||
// 单元素:单次上传记录 success 计数与 payload.bytes 分布,无并发上限触发。
|
||||
setConfigured(properties);
|
||||
|
||||
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
|
||||
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
|
||||
when(client.statObject(ArgumentMatchers.any(StatObjectArgs.class))).thenReturn(null);
|
||||
|
||||
service.uploadText("solo", "hello");
|
||||
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"));
|
||||
DistributionSummary summary = simpleRegistry.find("aiimage.rustfs.payload.bytes").summary();
|
||||
assertTrue(summary != null && summary.count() >= 1L, "payload 字节分布有记录");
|
||||
assertTrue(summary != null && summary.totalAmount() >= 5L, "记录字节数与内容一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_boundary_limit_and_overflow() throws Exception {
|
||||
// 上限/超限:上传并发上限 1 + 获取许可超时 0 → 第二并发请求被拒绝,
|
||||
// rejected 指标 +1,不发生无界排队。
|
||||
setConfigured(properties);
|
||||
properties.setMaxConcurrentUploads(1);
|
||||
properties.setAcquirePermitTimeoutMillis(0);
|
||||
// 信号量在构造时从 properties 固定,须先设置上限再构造受限实例。
|
||||
RustfsObjectStorageService limited = new RustfsObjectStorageService(
|
||||
properties, meterRegistryProvider, deleteRetryProvider, () -> client);
|
||||
|
||||
java.util.concurrent.CountDownLatch entered = new java.util.concurrent.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 {
|
||||
limited.uploadText("block", "v");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
first.setDaemon(true);
|
||||
first.start();
|
||||
try {
|
||||
assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS),
|
||||
"首线程已持有许可");
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> limited.uploadText("blocked", "v"),
|
||||
"并发超限被拒绝");
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "rejected"));
|
||||
first.interrupt();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_invalid_input_rejected() {
|
||||
// 非法参数:未配置时上传/读取抛可识别异常且零指标(不污染基线)。
|
||||
assertThrows(IllegalStateException.class, () -> service.uploadText("k", "v"),
|
||||
"未配置抛可识别异常");
|
||||
assertThrows(IllegalStateException.class, () -> service.readObjectAsString("k"),
|
||||
"未配置抛可识别异常");
|
||||
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "upload", "success"));
|
||||
assertEquals(0L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "success"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_061_rustfs_metrics_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:上传成功但 stat 可见性校验失败 → 失败窗口打开,
|
||||
// 后续读取被熔断拒绝(rejected 指标);熔断过期后自动恢复,指标完整。
|
||||
setConfigured(properties);
|
||||
properties.setFailureWindowThreshold(1);
|
||||
properties.setFailureCooldownMillis(5_000);
|
||||
properties.setFailureWindowSeconds(10);
|
||||
properties.setDeleteRetryEnabled(false);
|
||||
|
||||
when(client.putObject(ArgumentMatchers.any(PutObjectArgs.class)))
|
||||
.thenReturn(mock(io.minio.ObjectWriteResponse.class));
|
||||
doThrow(new java.io.IOException("stat down")).when(client)
|
||||
.statObject(ArgumentMatchers.any(StatObjectArgs.class));
|
||||
GetObjectResponse readResponse = readResponse("{}");
|
||||
when(client.getObject(ArgumentMatchers.any(io.minio.GetObjectArgs.class)))
|
||||
.thenReturn(readResponse);
|
||||
|
||||
assertThrows(Exception.class, () -> service.uploadText("k", "v"), "stat 失败上传抛错");
|
||||
assertThrows(IllegalStateException.class, () -> service.readObjectAsString("k"),
|
||||
"熔断打开读取被拒绝");
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "rejected"));
|
||||
|
||||
// 熔断冷却到期后自动恢复。
|
||||
ReflectionTestUtils.setField(service, "circuitOpenUntilMillis", 0L);
|
||||
assertEquals("{}", service.readObjectAsString("k"), "熔断重置后恢复");
|
||||
assertEquals(1L, counter(simpleRegistry, "aiimage.rustfs.operation.total", "read", "success"));
|
||||
Object openUntil = ReflectionTestUtils.getField(service, "circuitOpenUntilMillis");
|
||||
assertFalse(openUntil instanceof Long && (Long) openUntil > 0L, "熔断已复位");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user