task-61: Rustfs 对象存储指标基线验收测试(计数/计时/分布/熔断/拒绝)

This commit is contained in:
2026-08-30 18:00:33 +08:00
parent 7b3feb4b1a
commit 8e9c79fc0e
@@ -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, "熔断已复位");
}
}