task-79: 为对象存储、数据库和队列增加故障注入测试

This commit is contained in:
2026-08-30 22:23:33 +08:00
parent 42bbf4904e
commit ba17e34b84
@@ -0,0 +1,263 @@
package com.nanri.aiimage.modules.file.service.object;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.config.CozeTaskQueueGate;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.TaskRejectedException;
import java.lang.reflect.Field;
import java.util.concurrent.Semaphore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
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.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 79:为对象存储(RustFS/MinIO)、数据库(任务文件作业表)和队列
* (Coze 执行队列)增加故障注入测试。全部通过 mock 依赖注入故障,验证
* 错误可恢复、重试有界、信号量/等待槽释放、非法输入不创建无效资源。
*/
class FaultInjectionTest {
@BeforeAll
static void initializeTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskFileJobEntity.class);
}
// ---------- 1. 对象存储默认路径:一次成功写入占用并释放上传与总预算信号量 ----------
@Test
void test_task_079_object_storage_normal_default_path() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentUploads(2);
properties.setMaxTotalConcurrentOperations(2);
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
String key = service.uploadText("task/a.json", "{\"k\":1}", false);
assertEquals("task/a.json", key);
verify(client, times(1)).putObject(any(PutObjectArgs.class));
assertEquals(2, semaphorePermits(service, "uploadSemaphore"), "成功路径释放上传信号量");
assertEquals(2, semaphorePermits(service, "totalSemaphore"), "成功路径释放总预算信号量");
}
// ---------- 2. 对象存储批量:多对象全部成功,信号量全部释放,顺序稳定 ----------
@Test
void test_task_079_object_storage_normal_multiple_items() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentUploads(3);
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
service.uploadText("task/1.json", "1", false);
service.uploadText("task/2.json", "2", false);
service.uploadText("task/3.json", "3", false);
verify(client, times(3)).putObject(any(PutObjectArgs.class));
assertEquals(3, semaphorePermits(service, "uploadSemaphore"), "批量全部释放上传信号量");
}
// ---------- 3. 对象存储幂等:故障后重试一次成功,不产生重复对象,信号量精确释放 ----------
@Test
void test_task_079_object_storage_normal_repeated_operation_is_idempotent() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentUploads(2);
properties.setUploadMaxRetries(2);
properties.setBaseRetryDelayMillis(0);
properties.setRetryJitterMillis(0);
MinioClient client = mock(MinioClient.class);
doThrow(new IllegalStateException("first put failed"))
.doReturn(null)
.when(client).putObject(any(PutObjectArgs.class));
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
String key = service.uploadText("task/retry.json", "{}", false);
assertEquals("task/retry.json", key);
verify(client, times(2)).putObject(any(PutObjectArgs.class));
assertEquals(2, semaphorePermits(service, "uploadSemaphore"), "重试路径信号量仍精确释放");
}
// ---------- 4. 对象存储空输入:空对象安全跳过,不产生请求 ----------
@Test
void test_task_079_object_storage_boundary_empty_input() throws Exception {
TransientStorageProperties properties = configuredProperties();
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
service.deleteObject(null);
service.deleteObject(" ");
verify(client, never()).removeObject(any());
}
// ---------- 5. 对象存储单元素:单对象读取不依赖批量路径,信号量正确 ----------
@Test
void test_task_079_object_storage_boundary_single_item() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxConcurrentReads(1);
properties.setReadMaxRetries(1);
MinioClient client = mock(MinioClient.class);
when(client.getObject(any(io.minio.GetObjectArgs.class)))
.thenAnswer(invocation -> {
GetObjectResponse response = mock(GetObjectResponse.class);
when(response.readAllBytes()).thenReturn("{}".getBytes());
return response;
});
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
String content = service.readObjectAsString("task/single.json");
assertEquals("{}", content);
assertEquals(1, semaphorePermits(service, "readSemaphore"), "单元素读取释放信号量");
}
// ---------- 6. 对象存储上限/超限:总预算耗尽立即拒绝,不进入重试 ----------
@Test
void test_task_079_object_storage_boundary_limit_and_overflow() throws Exception {
TransientStorageProperties properties = configuredProperties();
properties.setMaxTotalConcurrentOperations(1);
properties.setMaxConcurrentUploads(1);
properties.setUploadMaxRetries(3);
properties.setAcquirePermitTimeoutMillis(0);
MinioClient client = mock(MinioClient.class);
RustfsObjectStorageService service = new RustfsObjectStorageService(
properties, FaultInjectionTest.<MeterRegistry>emptyProvider(),
FaultInjectionTest.<RustfsDeleteRetryService>emptyProvider(), () -> client);
service.uploadText("task/one.json", "{}", false);
// 手动占用总预算许可,模拟并发场景下预算耗尽(串行调用间许可已释放)
semaphore(service, "totalSemaphore").tryAcquire();
IllegalStateException rejected = assertThrows(IllegalStateException.class,
() -> service.uploadText("task/two.json", "{}", false));
assertTrue(rejected.getMessage().contains("concurrency limit"), "超限必须明确拒绝");
verify(client, times(1)).putObject(any(PutObjectArgs.class));
}
// ---------- 7. 非法参数:DB 入队非法参数安全拒绝,队列 null 任务抛可识别异常 ----------
@Test
void test_task_079_object_storage_invalid_input_rejected() {
TaskFileJobMapper mapper = mock(TaskFileJobMapper.class);
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
com.nanri.aiimage.modules.task.service.TaskFileJobService service =
new com.nanri.aiimage.modules.task.service.TaskFileJobService(mapper, publisher);
TaskFileJobEntity rejected = service.enqueueAssembleResult(null, "SIMILAR_ASIN", 23110L, "t-1");
TaskFileJobEntity rejectedBlank = service.enqueueAssembleResult(20553L, " ", 23110L, "t-1");
assertNull(rejected, "非法参数安全拒绝");
assertNull(rejectedBlank, "非法参数安全拒绝");
verify(mapper, never()).insert(any(TaskFileJobEntity.class));
verify(mapper, never()).selectOne(any());
verify(publisher, never()).publishEvent(any(TaskFileJobDispatchEvent.class));
CozeTaskQueueGate gate = new CozeTaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> gate.execute(null));
assertTrue(ex.getMessage().contains("不能为 null"), "null 任务必须抛出可识别错误消息");
}
// ---------- 8. 队列故障注入:执行器拒绝释放等待槽并记录指标,恢复后正常 ----------
@Test
void test_task_079_object_storage_dependency_failure_releases_resources() throws Exception {
TaskExecutor delegate = mock(TaskExecutor.class);
doThrow(new TaskRejectedException("executor full"))
.doAnswer(invocation -> {
((Runnable) invocation.getArgument(0)).run();
return null;
})
.when(delegate).execute(any(Runnable.class));
SimpleMeterRegistry registry = new SimpleMeterRegistry();
CozeTaskQueueGate gate = new CozeTaskQueueGate(delegate, 2, objectProvider(registry));
Runnable task = () -> {
};
// 第一次提交被执行器拒绝:等待槽释放、拒绝指标记录
assertThrows(TaskRejectedException.class, () -> gate.execute(task));
assertEquals(0, gate.waiting(), "执行器拒绝后等待槽必须释放");
assertTrue(registry.counter("aiimage.coze-task.submit.rejected.total", "reason", "delegate-rejected").count() > 0);
// 第二次提交恢复成功:等待槽正常占用并释放
gate.execute(task);
assertEquals(0, gate.waiting(), "成功执行后等待槽释放");
}
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;
}
private static int semaphorePermits(RustfsObjectStorageService service, String fieldName) throws Exception {
return semaphore(service, fieldName).availablePermits();
}
private static Semaphore semaphore(RustfsObjectStorageService service, String fieldName) throws Exception {
Field field = RustfsObjectStorageService.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (Semaphore) field.get(service);
}
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> objectProvider(T value) {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(value);
return provider;
}
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> emptyProvider() {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(null);
return provider;
}
}