fix: 完善自动导入异常中断与恢复

- 自动导入基础设施异常触发批次熔断,保留完整日志并输出安全错误信息

- 增加恢复令牌与租约围栏、无文档失败项重建及消息退避机制

- 前端展示中断状态并在状态请求失败后自动恢复轮询

- 补充批次中断迁移、配置与并发异常路径测试
This commit is contained in:
2026-08-10 11:26:00 +08:00
parent 54d85ae460
commit bae7b18977
33 changed files with 3725 additions and 221 deletions

View File

@@ -1,8 +1,10 @@
package tech.easyflow.ai.documentimport.task;
import com.mybatisflex.core.query.QueryWrapper;
import org.apache.ibatis.annotations.Update;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -29,10 +31,13 @@ import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;
/**
* {@link DocumentImportBatchAppService} 批次启动与重复策略回归测试。
@@ -577,6 +582,7 @@ public class DocumentImportBatchAppServiceTest {
Mockito.anyString(),
Mockito.any(),
Mockito.anyInt(),
Mockito.nullable(String.class),
Mockito.any(Date.class)
)).thenReturn(1);
@@ -601,6 +607,7 @@ public class DocumentImportBatchAppServiceTest {
Mockito.anyString(),
Mockito.any(),
Mockito.eq(0),
Mockito.eq("[\"file-key\"]"),
Mockito.any(Date.class)
);
Mockito.verify(context.lockHandle).release();
@@ -633,6 +640,7 @@ public class DocumentImportBatchAppServiceTest {
Mockito.anyString(),
Mockito.any(),
Mockito.anyInt(),
Mockito.nullable(String.class),
Mockito.any(Date.class)
)).thenReturn(1);
@@ -666,6 +674,7 @@ public class DocumentImportBatchAppServiceTest {
Mockito.anyString(),
Mockito.any(),
Mockito.eq(0),
Mockito.isNull(),
Mockito.any(Date.class)
);
Mockito.verify(context.lockHandle).release();
@@ -693,9 +702,8 @@ public class DocumentImportBatchAppServiceTest {
failed.setRetryable(false);
Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(failed));
Mockito.when(context.batchMapper.updateByQuery(
Mockito.any(DocumentImportBatch.class),
Mockito.any(QueryWrapper.class)
Mockito.when(context.batchMapper.claimContinue(
Mockito.eq(batch.getId()), Mockito.any(Date.class)
)).thenReturn(1);
Mockito.when(context.batchTracker.toStatusResponse(batch))
.thenAnswer(invocation -> {
@@ -719,15 +727,261 @@ public class DocumentImportBatchAppServiceTest {
Assert.assertEquals(DocumentImportBatchStatus.RUNNING.name(),
response.getStatus());
Mockito.verify(context.batchMapper).claimContinue(
Mockito.eq(batch.getId()), Mockito.any(Date.class));
Mockito.verify(context.batchTracker, Mockito.never())
.refreshBatch(batch.getId());
Mockito.verify(context.taskAppService).retryBatchFailures(
batch.getId(),
Set.of(failed.getClientFileKey())
Mockito.eq(batch.getId()),
Mockito.eq(Set.of()),
Mockito.any(BooleanSupplier.class)
);
Mockito.verify(context.lockHandle).release();
}
/**
* 验证继续后的恢复调度再次异常时通过统一熔断器收口整个批次。
*/
@Test
public void continueRecoveryFailureShouldInterruptWholeBatch() {
TestContext context = createContext();
DocumentImportBatch batch = context.batchService.getOne(
QueryWrapper.create()
);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.INTERRUPTED.name());
DocumentImportBatchItem failed = uploadedItem(
BigInteger.valueOf(34),
batch.getId()
);
failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(failed));
Mockito.when(context.batchMapper.claimContinue(
Mockito.eq(batch.getId()), Mockito.any(Date.class)
)).thenReturn(1);
Mockito.when(context.batchTracker.toStatusResponse(batch))
.thenReturn(new DocumentImportBatchDtos.StatusResponse());
IllegalStateException recoveryError =
new IllegalStateException("Redis unavailable");
Mockito.doThrow(recoveryError)
.when(context.taskAppService)
.retryBatchFailures(
Mockito.eq(batch.getId()),
Mockito.eq(Set.of()),
Mockito.any(BooleanSupplier.class)
);
beginTransactionSynchronization();
try {
context.service.continueBatch(
batch.getKnowledgeId(), batch.getId()
);
} finally {
completeTransactionSynchronization(
TransactionSynchronization.STATUS_COMMITTED
);
}
Mockito.verify(context.circuitBreaker)
.interruptRecoveryBatch(
Mockito.eq(batch.getId()),
Mockito.anyString(),
Mockito.same(recoveryError)
);
Mockito.verify(context.batchMapper, Mockito.never())
.finalizeRecoveryPending(
Mockito.any(), Mockito.anyString(), Mockito.any(Date.class));
}
/**
* 验证领取后恢复令牌已经失效时旧持有者直接退出且不触发批次熔断。
*/
@Test
public void staleRecoveryOwnerShouldStopWithoutInterruptingBatch() {
TestContext context = createContext();
DocumentImportBatch batch = context.batchService.getOne(
QueryWrapper.create()
);
batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
batch.setRecoveryPending(true);
Mockito.when(context.batchMapper.selectRecoveryPendingBatches(
Mockito.any(Date.class), Mockito.anyInt()
)).thenReturn(List.of(batch));
Mockito.when(context.batchMapper.selectClaimedRecovery(
Mockito.eq(batch.getId()), Mockito.anyString()
)).thenReturn(null);
int recovered = context.service.recoverPendingBatchRetries();
Assert.assertEquals(1, recovered);
Mockito.verifyNoInteractions(context.circuitBreaker);
Mockito.verify(context.taskAppService, Mockito.never())
.retryBatchFailures(
Mockito.any(), Mockito.anySet(),
Mockito.any(BooleanSupplier.class));
}
/**
* 验证进程错过提交后回调时,调度器可从持久恢复待办重放任务创建。
*/
@Test
public void pendingRecoveryShouldReplayAndClearDurableMarker() {
TestContext context = createContext();
DocumentImportBatch batch = context.batchService.getOne(
QueryWrapper.create()
);
batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
batch.setRecoveryPending(true);
batch.setRecoveryFileKeysJson("[\"stale-file\"]");
DocumentImportBatch claimedBatch = new DocumentImportBatch();
claimedBatch.setId(batch.getId());
claimedBatch.setRecoveryFileKeysJson("[\"selected-file\"]");
Mockito.when(context.batchMapper.selectRecoveryPendingBatches(
Mockito.any(Date.class), Mockito.anyInt()
)).thenReturn(List.of(batch));
Mockito.when(context.batchMapper.selectClaimedRecovery(
Mockito.eq(batch.getId()), Mockito.anyString()
)).thenReturn(claimedBatch);
int recovered = context.service.recoverPendingBatchRetries();
Assert.assertEquals(1, recovered);
Mockito.verify(context.taskAppService)
.retryBatchFailures(
Mockito.eq(batch.getId()),
Mockito.eq(Set.of("selected-file")),
Mockito.any(BooleanSupplier.class)
);
ArgumentCaptor<String> recoveryToken =
ArgumentCaptor.forClass(String.class);
Mockito.verify(context.batchMapper).claimRecoveryPending(
Mockito.eq(batch.getId()),
recoveryToken.capture(),
Mockito.any(Date.class),
Mockito.any(Date.class)
);
Mockito.verify(context.batchMapper).selectClaimedRecovery(
batch.getId(), recoveryToken.getValue());
Mockito.verify(context.batchMapper)
.finalizeRecoveryPending(
Mockito.eq(batch.getId()),
Mockito.eq(recoveryToken.getValue()),
Mockito.any(Date.class));
}
/**
* 验证文件恢复期间租约失效后旧持有者不会清理持久恢复待办。
*/
@Test
public void lostRecoveryLeaseShouldKeepDurableMarker() {
TestContext context = createContext();
DocumentImportBatch batch = context.batchService.getOne(
QueryWrapper.create()
);
batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
batch.setRecoveryPending(true);
Mockito.when(context.batchMapper.selectRecoveryPendingBatches(
Mockito.any(Date.class), Mockito.anyInt()
)).thenReturn(List.of(batch));
Mockito.when(context.taskAppService.retryBatchFailures(
Mockito.eq(batch.getId()), Mockito.anySet(),
Mockito.any(BooleanSupplier.class)
)).thenReturn(false);
Assert.assertEquals(1, context.service.recoverPendingBatchRetries());
Mockito.verify(context.batchMapper, Mockito.never())
.finalizeRecoveryPending(
Mockito.any(), Mockito.anyString(), Mockito.any(Date.class));
Mockito.verifyNoInteractions(context.circuitBreaker);
}
/**
* 验证长时间恢复会在半租期续租,并在令牌失效后停止旧实例。
*/
@Test
public void recoveryLeaseGuardShouldRenewAndStopWhenTokenIsLost() {
TestContext context = createContext();
BigInteger batchId = BigInteger.valueOf(84);
String recoveryToken = "recovery-token";
AtomicLong now = new AtomicLong(1_000L);
Mockito.when(context.batchMapper.renewRecoveryPendingLease(
Mockito.eq(batchId),
Mockito.eq(recoveryToken),
Mockito.any(Date.class),
Mockito.any(Date.class)
)).thenReturn(1, 0);
BooleanSupplier guard = context.service.createRecoveryLeaseGuard(
batchId,
recoveryToken,
121_000L,
now::get
);
Assert.assertTrue(guard.getAsBoolean());
Mockito.verify(context.batchMapper, Mockito.never())
.renewRecoveryPendingLease(
Mockito.any(),
Mockito.anyString(),
Mockito.any(Date.class),
Mockito.any(Date.class)
);
now.set(61_000L);
Assert.assertTrue(guard.getAsBoolean());
now.set(121_000L);
Assert.assertFalse(guard.getAsBoolean());
Mockito.verify(context.batchMapper, Mockito.times(2))
.renewRecoveryPendingLease(
Mockito.eq(batchId),
Mockito.eq(recoveryToken),
Mockito.any(Date.class),
Mockito.any(Date.class)
);
}
/**
* 验证恢复租约续期 SQL 同时校验批次状态、令牌和未过期租约。
*
* @throws Exception Mapper 方法不存在时抛出
*/
@Test
public void recoveryLeaseRenewalSqlShouldFenceCurrentOwner()
throws Exception {
Method method = DocumentImportBatchMapper.class.getMethod(
"renewRecoveryPendingLease",
BigInteger.class,
String.class,
Date.class,
Date.class
);
Update update = method.getAnnotation(Update.class);
String sql = String.join(" ", update.value());
Assert.assertTrue(sql.contains("status='RUNNING'"));
Assert.assertTrue(sql.contains("recovery_pending=1"));
Assert.assertTrue(sql.contains("recovery_token=#{recoveryToken}"));
Assert.assertTrue(sql.contains("recovery_lease_until > #{modified}"));
Method finalizeMethod = DocumentImportBatchMapper.class.getMethod(
"finalizeRecoveryPending",
BigInteger.class,
String.class,
Date.class
);
String finalizeSql = String.join(" ",
finalizeMethod.getAnnotation(Update.class).value());
Assert.assertTrue(finalizeSql.contains(
"status=CASE WHEN"));
Assert.assertTrue(finalizeSql.contains("recovery_pending=0"));
Assert.assertTrue(finalizeSql.contains(
"recovery_token=#{recoveryToken}"));
Assert.assertTrue(finalizeSql.contains(
"recovery_lease_until > #{modified}"));
}
/**
* 验证运行中的任务拒绝再次领取,调用方可继续查询原 taskId。
*/
@@ -761,6 +1015,50 @@ public class DocumentImportBatchAppServiceTest {
Mockito.anyString(),
Mockito.any(),
Mockito.anyInt(),
Mockito.nullable(String.class),
Mockito.any(Date.class)
);
Mockito.verify(context.lockHandle).release();
}
/**
* 验证 Public API 不会在同一知识库已有运行批次时恢复旧批次。
*/
@Test
public void retryShouldRejectWhenAnotherAutoBatchIsRunning() {
TestContext context = createContext();
DocumentImportBatch batch = context.batchService.getOne(
QueryWrapper.create()
);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.INTERRUPTED.name());
Mockito.when(context.batchService.count(Mockito.any(QueryWrapper.class)))
.thenReturn(1L);
beginTransactionSynchronization();
try {
context.service.retryOwnedBatch(
batch.getId(),
publicCaller(),
Set.of()
);
Assert.fail("Expected active automatic batch rejection");
} catch (BusinessException expected) {
Assert.assertTrue(
expected.getMessage().contains("当前知识库已有自动导入批次")
);
} finally {
completeTransactionSynchronization(
TransactionSynchronization.STATUS_ROLLED_BACK
);
}
Mockito.verify(context.batchMapper, Mockito.never()).claimRetry(
Mockito.any(),
Mockito.anyString(),
Mockito.any(),
Mockito.anyInt(),
Mockito.nullable(String.class),
Mockito.any(Date.class)
);
Mockito.verify(context.lockHandle).release();
@@ -814,6 +1112,8 @@ public class DocumentImportBatchAppServiceTest {
Mockito.mock(DocumentImportBatchItemMapper.class);
DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
RedisLockExecutor redisLockExecutor = Mockito.mock(RedisLockExecutor.class);
DocumentImportBatchCircuitBreaker circuitBreaker =
Mockito.mock(DocumentImportBatchCircuitBreaker.class);
RedisLockExecutor.LockHandle lockHandle = Mockito.mock(RedisLockExecutor.LockHandle.class);
Mockito.when(redisLockExecutor.tryAcquire(
Mockito.anyString(), Mockito.any(), Mockito.any()
@@ -829,6 +1129,21 @@ public class DocumentImportBatchAppServiceTest {
Mockito.when(batchMapper.selectOwnedForUpdate(
Mockito.any(), Mockito.anyString(), Mockito.any()
)).thenReturn(batch);
Mockito.when(batchMapper.claimRecoveryPending(
Mockito.any(),
Mockito.anyString(),
Mockito.any(Date.class),
Mockito.any(Date.class)
)).thenReturn(1);
Mockito.when(batchMapper.selectClaimedRecovery(
Mockito.any(), Mockito.anyString()
)).thenReturn(batch);
Mockito.when(taskAppService.retryBatchFailures(
Mockito.any(), Mockito.anySet(), Mockito.any(BooleanSupplier.class)
)).thenReturn(true);
Mockito.when(batchMapper.finalizeRecoveryPending(
Mockito.any(), Mockito.anyString(), Mockito.any(Date.class)
)).thenReturn(1);
DocumentImportBatchAppService service = new DocumentImportBatchAppService(
batchService,
@@ -839,11 +1154,13 @@ public class DocumentImportBatchAppServiceTest {
batchMapper,
itemMapper,
documentMapper,
redisLockExecutor
redisLockExecutor,
circuitBreaker
);
return new TestContext(
service, batchService, itemService, batchTracker,
taskAppService, batchMapper, itemMapper, documentMapper, lockHandle
taskAppService, batchMapper, itemMapper, documentMapper,
circuitBreaker, lockHandle
);
}
@@ -954,6 +1271,7 @@ public class DocumentImportBatchAppServiceTest {
DocumentImportBatchMapper batchMapper,
DocumentImportBatchItemMapper itemMapper,
DocumentMapper documentMapper,
DocumentImportBatchCircuitBreaker circuitBreaker,
RedisLockExecutor.LockHandle lockHandle
) {
}

View File

@@ -0,0 +1,297 @@
package tech.easyflow.ai.documentimport.task;
import org.apache.ibatis.annotations.Update;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.data.redis.RedisSystemException;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.entity.DocumentImportTask;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.enums.DocumentImportTaskPhase;
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
import tech.easyflow.ai.mapper.DocumentImportTaskMapper;
import java.math.BigInteger;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.Date;
/**
* {@link DocumentImportBatchCircuitBreaker} 批次熔断回归测试。
*/
public class DocumentImportBatchCircuitBreakerTest {
/**
* 验证 Redis 超时会强制中断批次、撤销任务并刷新真实计数。
*/
@Test
public void shouldInterruptAutomaticBatchOnRedisFailure() {
BigInteger taskId = BigInteger.valueOf(11);
BigInteger batchId = BigInteger.valueOf(12);
DocumentImportTask task = new DocumentImportTask();
task.setId(taskId);
task.setBatchId(batchId);
task.setPhase(DocumentImportTaskPhase.PARSE.name());
task.setStatus(DocumentImportTaskStatus.PENDING.name());
DocumentImportBatch batch = new DocumentImportBatch();
batch.setId(batchId);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
DocumentImportBatchMapper batchMapper =
Mockito.mock(DocumentImportBatchMapper.class);
DocumentImportBatchItemMapper itemMapper =
Mockito.mock(DocumentImportBatchItemMapper.class);
DocumentImportTaskMapper taskMapper =
Mockito.mock(DocumentImportTaskMapper.class);
Mockito.when(taskMapper.selectOneById(taskId)).thenReturn(task);
Mockito.when(batchMapper.selectOneById(batchId)).thenReturn(batch);
Mockito.when(batchMapper.interruptRunningBatchForActiveTask(
Mockito.eq(batchId),
Mockito.eq(taskId),
Mockito.eq("redis_unavailable"),
Mockito.anyString(),
Mockito.any(Date.class)
)).thenReturn(1);
Mockito.when(taskMapper.interruptActiveDocuments(
Mockito.eq(batchId), Mockito.anyString(),
Mockito.eq("redis_unavailable"), Mockito.any(Date.class),
Mockito.eq(BigInteger.ZERO)
)).thenReturn(1);
Mockito.when(taskMapper.interruptActiveTasks(
Mockito.eq(batchId), Mockito.anyString(),
Mockito.eq("redis_unavailable"), Mockito.any(Date.class),
Mockito.eq(BigInteger.ZERO)
)).thenReturn(2);
Mockito.when(itemMapper.interruptActiveItems(
Mockito.eq(batchId), Mockito.anyString(),
Mockito.eq("redis_unavailable"), Mockito.any(Date.class)
)).thenReturn(2);
DocumentImportBatchCircuitBreaker circuitBreaker =
new DocumentImportBatchCircuitBreaker(
batchMapper, itemMapper, taskMapper);
boolean interrupted = circuitBreaker.interruptTaskBatch(
taskId,
new RedisSystemException(
"Redis command timed out", new IllegalStateException("timeout"))
);
Assert.assertTrue(interrupted);
Mockito.verify(batchMapper).refreshCountersFromItems(
Mockito.eq(batchId), Mockito.any(Date.class));
}
/**
* 验证无批次任务不会误触发批次熔断。
*/
@Test
public void shouldLeaveStandaloneTaskForDeferredRetry() {
BigInteger taskId = BigInteger.valueOf(21);
DocumentImportTask task = new DocumentImportTask();
task.setId(taskId);
DocumentImportBatchMapper batchMapper =
Mockito.mock(DocumentImportBatchMapper.class);
DocumentImportBatchItemMapper itemMapper =
Mockito.mock(DocumentImportBatchItemMapper.class);
DocumentImportTaskMapper taskMapper =
Mockito.mock(DocumentImportTaskMapper.class);
Mockito.when(taskMapper.selectOneById(taskId)).thenReturn(task);
DocumentImportBatchCircuitBreaker circuitBreaker =
new DocumentImportBatchCircuitBreaker(
batchMapper, itemMapper, taskMapper);
Assert.assertFalse(circuitBreaker.interruptTaskBatch(
taskId, new IllegalStateException("temporary failure")));
Mockito.verify(batchMapper, Mockito.never()).interruptRunningBatch(
Mockito.any(), Mockito.anyString(), Mockito.anyString(),
Mockito.any(Date.class));
}
/**
* 验证旧任务已结束后,迟到消费者异常不会中断新一轮运行批次。
*/
@Test
public void shouldIgnoreLateFailureFromFinishedTask() {
BigInteger taskId = BigInteger.valueOf(31);
DocumentImportTask task = new DocumentImportTask();
task.setId(taskId);
task.setBatchId(BigInteger.valueOf(32));
task.setStatus(DocumentImportTaskStatus.FAILED.name());
DocumentImportBatchMapper batchMapper =
Mockito.mock(DocumentImportBatchMapper.class);
DocumentImportBatchItemMapper itemMapper =
Mockito.mock(DocumentImportBatchItemMapper.class);
DocumentImportTaskMapper taskMapper =
Mockito.mock(DocumentImportTaskMapper.class);
Mockito.when(taskMapper.selectOneById(taskId)).thenReturn(task);
DocumentImportBatchCircuitBreaker circuitBreaker =
new DocumentImportBatchCircuitBreaker(
batchMapper, itemMapper, taskMapper);
Assert.assertTrue(circuitBreaker.interruptTaskBatch(
taskId, new IllegalStateException("迟到异常")));
Mockito.verify(batchMapper, Mockito.never())
.interruptRunningBatchForActiveTask(
Mockito.any(), Mockito.any(), Mockito.anyString(),
Mockito.anyString(), Mockito.any(Date.class));
Mockito.verify(batchMapper, Mockito.never()).selectOneById(Mockito.any());
}
/**
* 验证恢复令牌失效后旧持有者无法中断仍由新持有者运行的批次。
*/
@Test
public void shouldRejectInterruptionFromStaleRecoveryOwner() {
BigInteger batchId = BigInteger.valueOf(41);
DocumentImportBatch batch = new DocumentImportBatch();
batch.setId(batchId);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
DocumentImportBatchMapper batchMapper =
Mockito.mock(DocumentImportBatchMapper.class);
DocumentImportBatchItemMapper itemMapper =
Mockito.mock(DocumentImportBatchItemMapper.class);
DocumentImportTaskMapper taskMapper =
Mockito.mock(DocumentImportTaskMapper.class);
Mockito.when(batchMapper.selectOneById(batchId)).thenReturn(batch);
Mockito.when(batchMapper.interruptOwnedRecoveryBatch(
Mockito.eq(batchId),
Mockito.eq("stale-token"),
Mockito.anyString(),
Mockito.anyString(),
Mockito.any(Date.class)
)).thenReturn(0);
DocumentImportBatchCircuitBreaker circuitBreaker =
new DocumentImportBatchCircuitBreaker(
batchMapper, itemMapper, taskMapper);
Assert.assertFalse(circuitBreaker.interruptRecoveryBatch(
batchId,
"stale-token",
new IllegalStateException("旧持有者异常")
));
Mockito.verifyNoInteractions(itemMapper);
Mockito.verify(taskMapper, Mockito.never()).interruptActiveTasks(
Mockito.any(), Mockito.anyString(), Mockito.anyString(),
Mockito.any(Date.class), Mockito.any());
}
/**
* 验证 SQL 语法错误保留为通用系统异常,避免伪装成数据库不可用。
*/
@Test
public void shouldKeepBadSqlGrammarAsInfrastructureFailure() {
BigInteger batchId = BigInteger.valueOf(51);
DocumentImportBatch batch = runningAutoBatch(batchId);
DocumentImportBatchMapper batchMapper =
Mockito.mock(DocumentImportBatchMapper.class);
Mockito.when(batchMapper.selectOneById(batchId)).thenReturn(batch);
Mockito.when(batchMapper.interruptRunningBatch(
Mockito.eq(batchId),
Mockito.eq("document_import_infrastructure_failure"),
Mockito.anyString(),
Mockito.any(Date.class)
)).thenReturn(1);
DocumentImportBatchCircuitBreaker circuitBreaker =
new DocumentImportBatchCircuitBreaker(
batchMapper,
Mockito.mock(DocumentImportBatchItemMapper.class),
Mockito.mock(DocumentImportTaskMapper.class)
);
Assert.assertTrue(circuitBreaker.interruptBatch(
batchId,
new BadSqlGrammarException(
"query", "SELECT broken", new SQLException("syntax", "42000"))
));
}
/**
* 验证明确的数据库资源故障仍归类为数据库不可用。
*/
@Test
public void shouldClassifyDatabaseResourceFailureAsUnavailable() {
BigInteger batchId = BigInteger.valueOf(61);
DocumentImportBatch batch = runningAutoBatch(batchId);
DocumentImportBatchMapper batchMapper =
Mockito.mock(DocumentImportBatchMapper.class);
Mockito.when(batchMapper.selectOneById(batchId)).thenReturn(batch);
Mockito.when(batchMapper.interruptRunningBatch(
Mockito.eq(batchId),
Mockito.eq("database_unavailable"),
Mockito.anyString(),
Mockito.any(Date.class)
)).thenReturn(1);
DocumentImportBatchCircuitBreaker circuitBreaker =
new DocumentImportBatchCircuitBreaker(
batchMapper,
Mockito.mock(DocumentImportBatchItemMapper.class),
Mockito.mock(DocumentImportTaskMapper.class)
);
Assert.assertTrue(circuitBreaker.interruptBatch(
batchId,
new DataAccessResourceFailureException("connection unavailable")
));
}
/**
* 验证恢复与任务熔断 SQL 都包含对应所有权围栏。
*
* @throws Exception Mapper 方法不存在时抛出
*/
@Test
public void interruptionSqlShouldFenceRecoveryAndActiveTask()
throws Exception {
Method recoveryMethod = DocumentImportBatchMapper.class.getMethod(
"interruptOwnedRecoveryBatch",
BigInteger.class,
String.class,
String.class,
String.class,
Date.class
);
String recoverySql = String.join(" ",
recoveryMethod.getAnnotation(Update.class).value());
Assert.assertTrue(recoverySql.contains("recovery_pending=1"));
Assert.assertTrue(recoverySql.contains(
"recovery_token=#{recoveryToken}"));
Assert.assertTrue(recoverySql.contains(
"recovery_lease_until > #{modified}"));
Method taskMethod = DocumentImportBatchMapper.class.getMethod(
"interruptRunningBatchForActiveTask",
BigInteger.class,
BigInteger.class,
String.class,
String.class,
Date.class
);
String taskSql = String.join(" ",
taskMethod.getAnnotation(Update.class).value());
Assert.assertTrue(taskSql.contains("task.id=#{taskId}"));
Assert.assertTrue(taskSql.contains(
"task.status IN ('PENDING','RUNNING')"));
}
/**
* 创建运行中的自动导入批次。
*
* @param batchId 批次 ID
* @return 测试批次
*/
private DocumentImportBatch runningAutoBatch(BigInteger batchId) {
DocumentImportBatch batch = new DocumentImportBatch();
batch.setId(batchId);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
return batch;
}
}

View File

@@ -75,6 +75,9 @@ public class DocumentImportBatchTrackerTest {
batch.setCompletedCount(1);
batch.setFailedCount(1);
batch.setRetryableFailedCount(0);
batch.setInterruptCode("redis_unavailable");
batch.setInterruptMessage("缓存与消息服务异常");
batch.setInterruptedAt(new Date());
when(batchService.getById(batch.getId())).thenReturn(batch);
DocumentImportBatchTracker tracker =
@@ -84,6 +87,9 @@ public class DocumentImportBatchTrackerTest {
assertEquals(DocumentImportBatchStatus.INTERRUPTED.name(), response.getStatus());
assertEquals(100, response.getProgressPercent().intValue());
assertEquals(1, response.getRetryableFailedCount().intValue());
assertEquals("redis_unavailable", response.getInterruptCode());
assertEquals("缓存与消息服务异常", response.getInterruptMessage());
assertEquals(batch.getInterruptedAt(), response.getInterruptedAt());
}
/**
@@ -139,6 +145,45 @@ public class DocumentImportBatchTrackerTest {
);
}
/**
* 验证无文档失败项绑定后按真实文件状态刷新批次计数。
*/
@Test
public void shouldBindRecoveredDocumentAndRefreshCounters() {
DocumentImportBatchService batchService =
mock(DocumentImportBatchService.class);
DocumentImportBatchItemService itemService =
mock(DocumentImportBatchItemService.class);
DocumentImportBatchMapper batchMapper =
mock(DocumentImportBatchMapper.class);
DocumentImportBatchItemMapper itemMapper =
mock(DocumentImportBatchItemMapper.class);
DocumentImportBatch batch = batch(DocumentImportBatchStatus.RUNNING, 1);
batch.setPendingCount(1);
DocumentImportBatchItem item = new DocumentImportBatchItem();
item.setId(BigInteger.TEN);
item.setBatchId(batch.getId());
item.setStatus(DocumentImportBatchItemStatus.FAILED.name());
item.setRetryable(true);
BigInteger documentId = BigInteger.valueOf(99);
when(itemService.getById(item.getId())).thenReturn(item);
when(itemMapper.bindFailedDocument(
eq(item.getId()), eq(documentId), any(Date.class)
)).thenReturn(1);
when(batchService.getById(batch.getId())).thenReturn(batch);
DocumentImportBatchTracker tracker =
new DocumentImportBatchTracker(
batchService, itemService, batchMapper, itemMapper);
tracker.bindDocument(item.getId(), documentId);
verify(batchMapper).refreshCountersFromItems(
eq(batch.getId()), any(Date.class));
verify(batchMapper, never()).adjustCounters(
any(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(),
anyInt(), anyInt(), anyInt(), any(Date.class));
}
/**
* 验证迟到任务不能把已完成文件重新改为处理中。
*/

View File

@@ -0,0 +1,36 @@
package tech.easyflow.ai.documentimport.task;
import org.junit.Test;
import org.mockito.Mockito;
/**
* {@link DocumentImportPendingTaskMonitor} 调度退避回归测试。
*
* @author Codex
* @since 2026-08-07
*/
public class DocumentImportPendingTaskMonitorTest {
/**
* 验证任务投递异常后立即进入冷却期,避免短周期重复扫描。
*/
@Test
public void dispatchFailureShouldEnterCooldown() {
KnowledgeDocumentImportTaskAppService appService =
Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
DocumentImportBatchAppService batchAppService =
Mockito.mock(DocumentImportBatchAppService.class);
Mockito.doThrow(new IllegalStateException("Redis unavailable"))
.when(appService)
.dispatchPendingTasks();
DocumentImportPendingTaskMonitor monitor =
new DocumentImportPendingTaskMonitor(appService, batchAppService);
monitor.dispatchPendingTasks();
monitor.dispatchPendingTasks();
Mockito.verify(batchAppService, Mockito.times(1))
.recoverPendingBatchRetries();
Mockito.verify(appService, Mockito.times(1)).dispatchPendingTasks();
}
}

View File

@@ -14,11 +14,15 @@ import com.easyagents.rag.ingestion.RagIngestionService;
import com.easyagents.rag.ingestion.model.AnalysisResult;
import com.easyagents.rag.ingestion.model.StrategyConfig;
import com.easyagents.search.engine.service.DocumentSearcher;
import com.mybatisflex.core.query.QueryWrapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.data.redis.RedisSystemException;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentParseArtifacts;
@@ -28,20 +32,26 @@ import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.entity.DocumentImportBatchItem;
import tech.easyflow.ai.entity.DocumentImportTask;
import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
import tech.easyflow.ai.enums.DocumentImportTaskPhase;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.mapper.DocumentImportTaskMapper;
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.service.DocumentImportBatchItemService;
import tech.easyflow.ai.service.DocumentImportTaskService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.DocumentService;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Constructor;
@@ -59,6 +69,7 @@ import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
/**
@@ -192,6 +203,277 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
Mockito.verify(producer, Mockito.never()).send(Mockito.any());
}
/**
* 验证自动导入任务投递异常会触发批次熔断,且不会阻断本轮扫描。
*
* @throws Exception 反射注入异常
*/
@Test
public void dispatchPendingTasksShouldInterruptBatchWhenProducerFails()
throws Exception {
BigInteger taskId = BigInteger.valueOf(31);
DocumentImportTask task = new DocumentImportTask();
task.setId(taskId);
task.setBatchId(BigInteger.valueOf(32));
task.setPhase(DocumentImportTaskPhase.PARSE.name());
task.setStatus(DocumentImportTaskStatus.PENDING.name());
DocumentImportTaskMapper taskMapper =
Mockito.mock(DocumentImportTaskMapper.class);
Mockito.when(taskMapper.selectPendingFairly(
Mockito.any(Date.class), Mockito.anyInt()))
.thenReturn(List.of(task));
Mockito.when(taskMapper.touchPendingForDispatch(
Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
Mockito.any(BigInteger.class)))
.thenReturn(1);
DocumentImportParseTaskProducer producer =
Mockito.mock(DocumentImportParseTaskProducer.class);
IllegalStateException failure =
new IllegalStateException("Redis command timed out");
Mockito.doThrow(failure).when(producer).send(taskId);
DocumentImportBatchCircuitBreaker circuitBreaker =
Mockito.mock(DocumentImportBatchCircuitBreaker.class);
Mockito.when(circuitBreaker.interruptTaskBatch(taskId, failure))
.thenReturn(true);
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
setField(service, "documentImportTaskMapper", taskMapper);
setField(service, "parseTaskProducer", producer);
setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
setField(service, "bulkProperties", new DocumentImportBulkProperties());
service.dispatchPendingTasks();
Mockito.verify(circuitBreaker).interruptTaskBatch(taskId, failure);
}
/**
* 验证投递失败后若批次中断写入也失败,异常会传播给调度器触发退避。
*
* @throws Exception 反射注入异常
*/
@Test
public void dispatchPendingTasksShouldPropagateInterruptionWriteFailure()
throws Exception {
BigInteger taskId = BigInteger.valueOf(33);
DocumentImportTask task = new DocumentImportTask();
task.setId(taskId);
task.setBatchId(BigInteger.valueOf(34));
task.setPhase(DocumentImportTaskPhase.PARSE.name());
task.setStatus(DocumentImportTaskStatus.PENDING.name());
DocumentImportTaskMapper taskMapper =
Mockito.mock(DocumentImportTaskMapper.class);
Mockito.when(taskMapper.selectPendingFairly(
Mockito.any(Date.class), Mockito.anyInt()))
.thenReturn(List.of(task));
Mockito.when(taskMapper.touchPendingForDispatch(
Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
Mockito.any(BigInteger.class)))
.thenReturn(1);
DocumentImportParseTaskProducer producer =
Mockito.mock(DocumentImportParseTaskProducer.class);
RedisSystemException dispatchFailure = new RedisSystemException(
"Redis command timed out",
new IllegalStateException("timeout")
);
Mockito.doThrow(dispatchFailure).when(producer).send(taskId);
DocumentImportBatchCircuitBreaker circuitBreaker =
Mockito.mock(DocumentImportBatchCircuitBreaker.class);
IllegalStateException interruptionFailure =
new IllegalStateException("database unavailable");
Mockito.when(circuitBreaker.interruptTaskBatch(taskId, dispatchFailure))
.thenThrow(interruptionFailure);
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
setField(service, "documentImportTaskMapper", taskMapper);
setField(service, "parseTaskProducer", producer);
setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
setField(service, "bulkProperties", new DocumentImportBulkProperties());
try {
service.dispatchPendingTasks();
Assert.fail("批次中断写入失败必须传播给外层调度器");
} catch (IllegalStateException error) {
Assert.assertSame(interruptionFailure, error.getCause());
Assert.assertEquals(1, error.getSuppressed().length);
Assert.assertSame(dispatchFailure, error.getSuppressed()[0]);
Assert.assertTrue(error.getMessage().contains("taskId=33"));
Assert.assertTrue(error.getMessage().contains("batchId=34"));
}
}
/**
* 验证无批次任务投递失败时保留任务并传播异常,避免两秒热循环。
*
* @throws Exception 反射注入异常
*/
@Test
public void dispatchPendingTasksShouldPropagateUnbatchedFailure()
throws Exception {
BigInteger taskId = BigInteger.valueOf(35);
DocumentImportTask task = new DocumentImportTask();
task.setId(taskId);
task.setPhase(DocumentImportTaskPhase.PARSE.name());
task.setStatus(DocumentImportTaskStatus.PENDING.name());
DocumentImportTaskMapper taskMapper =
Mockito.mock(DocumentImportTaskMapper.class);
Mockito.when(taskMapper.selectPendingFairly(
Mockito.any(Date.class), Mockito.anyInt()))
.thenReturn(List.of(task));
Mockito.when(taskMapper.touchPendingForDispatch(
Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
Mockito.any(BigInteger.class)))
.thenReturn(1);
DocumentImportParseTaskProducer producer =
Mockito.mock(DocumentImportParseTaskProducer.class);
IllegalStateException dispatchFailure =
new IllegalStateException("Redis unavailable");
Mockito.doThrow(dispatchFailure).when(producer).send(taskId);
DocumentImportBatchCircuitBreaker circuitBreaker =
Mockito.mock(DocumentImportBatchCircuitBreaker.class);
Mockito.when(circuitBreaker.interruptTaskBatch(taskId, dispatchFailure))
.thenReturn(false);
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
setField(service, "documentImportTaskMapper", taskMapper);
setField(service, "parseTaskProducer", producer);
setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
setField(service, "bulkProperties", new DocumentImportBulkProperties());
try {
service.dispatchPendingTasks();
Assert.fail("无批次投递失败必须传播给外层调度器");
} catch (IllegalStateException error) {
Assert.assertSame(dispatchFailure, error.getCause());
Assert.assertTrue(error.getMessage().contains("taskId=35"));
Assert.assertTrue(error.getMessage().contains("batchId=null"));
}
}
/**
* 验证批次恢复遇到基础设施异常后向令牌持有者传播并停止后续重试。
*
* @throws Exception 反射注入异常
*/
@Test
public void retryBatchInfrastructureFailureShouldPropagateToOwner()
throws Exception {
BigInteger batchId = BigInteger.valueOf(91);
DocumentImportBatchItem first = new DocumentImportBatchItem();
first.setId(BigInteger.valueOf(92));
first.setBatchId(batchId);
DocumentImportBatchItem second = new DocumentImportBatchItem();
second.setId(BigInteger.valueOf(93));
second.setBatchId(batchId);
DocumentImportBatchItemService itemService =
Mockito.mock(DocumentImportBatchItemService.class);
Mockito.when(itemService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(first, second));
KnowledgeDocumentImportTaskAppService selfProxy =
Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
RedisSystemException failure = new RedisSystemException(
"Redis command timed out",
new IllegalStateException("timeout")
);
Mockito.doThrow(failure)
.when(selfProxy)
.retryBatchItemInNewTransaction(first.getId());
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
setField(service, "documentImportBatchItemService", itemService);
setField(service, "selfProxy", selfProxy);
try {
service.retryBatchFailures(batchId, Set.of());
Assert.fail("基础设施异常必须传播给恢复令牌持有者");
} catch (RedisSystemException error) {
Assert.assertSame(failure, error);
}
Mockito.verify(selfProxy, Mockito.never())
.retryBatchItemInNewTransaction(second.getId());
}
/**
* 验证单文件业务失败不会提前关闭批次并跳过后续失败项。
*
* @throws Exception 反射注入异常
*/
@Test
public void retryBatchBusinessFailureShouldContinueRemainingItems()
throws Exception {
BigInteger batchId = BigInteger.valueOf(96);
DocumentImportBatchItem first = new DocumentImportBatchItem();
first.setId(BigInteger.valueOf(97));
first.setBatchId(batchId);
first.setDocumentId(BigInteger.valueOf(98));
DocumentImportBatchItem second = new DocumentImportBatchItem();
second.setId(BigInteger.valueOf(99));
second.setBatchId(batchId);
second.setDocumentId(BigInteger.valueOf(100));
DocumentImportBatchItemService itemService =
Mockito.mock(DocumentImportBatchItemService.class);
Mockito.when(itemService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(first, second));
KnowledgeDocumentImportTaskAppService selfProxy =
Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
Mockito.doThrow(new BusinessException("格式不支持"))
.when(selfProxy)
.retryBatchItemInNewTransaction(first.getId());
DocumentImportBatchItemMapper itemMapper =
Mockito.mock(DocumentImportBatchItemMapper.class);
DocumentImportBatchTracker batchTracker =
Mockito.mock(DocumentImportBatchTracker.class);
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
setField(service, "documentImportBatchItemService", itemService);
setField(service, "documentImportBatchItemMapper", itemMapper);
setField(service, "documentImportBatchTracker", batchTracker);
setField(service, "selfProxy", selfProxy);
Assert.assertTrue(service.retryBatchFailures(
batchId, Set.of(), () -> true));
org.mockito.InOrder order = Mockito.inOrder(
selfProxy, itemMapper);
order.verify(selfProxy).retryBatchItemInNewTransaction(first.getId());
order.verify(itemMapper).updateFailedRetryError(
Mockito.eq(first.getId()), Mockito.eq(batchId),
Mockito.eq("格式不支持"), Mockito.any(Date.class));
order.verify(selfProxy).retryBatchItemInNewTransaction(second.getId());
Mockito.verifyNoInteractions(batchTracker);
}
/**
* 验证恢复租约失效后旧实例停止重试剩余文件。
*
* @throws Exception 反射注入异常
*/
@Test
public void retryBatchFailuresShouldStopWhenRecoveryLeaseIsLost()
throws Exception {
BigInteger batchId = BigInteger.valueOf(94);
DocumentImportBatchItem failed = new DocumentImportBatchItem();
failed.setId(BigInteger.valueOf(95));
failed.setBatchId(batchId);
DocumentImportBatchItemService itemService =
Mockito.mock(DocumentImportBatchItemService.class);
Mockito.when(itemService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(failed));
KnowledgeDocumentImportTaskAppService selfProxy =
Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
setField(service, "documentImportBatchItemService", itemService);
setField(service, "selfProxy", selfProxy);
Assert.assertFalse(service.retryBatchFailures(
batchId, Set.of(), () -> false));
Mockito.verify(selfProxy, Mockito.never())
.retryBatchItemInNewTransaction(Mockito.any());
}
/**
* 验证重新投递资格更新包含待处理状态和过期时间边界,确保竞争更新仅一个成功。
*
@@ -213,6 +495,102 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
Assert.assertTrue(sql.contains("status='PENDING'"));
Assert.assertTrue(sql.contains("modified <= #{redispatchBefore}"));
Assert.assertTrue(sql.contains("modified=#{now}"));
Assert.assertTrue(sql.contains("batch.status='RUNNING'"));
}
/**
* 验证任务扫描、领取、续租和完成 SQL 均包含批次运行状态门禁。
*/
@Test
public void activeTaskSqlShouldFenceInterruptedBatch() {
for (Method method : DocumentImportTaskMapper.class.getDeclaredMethods()) {
String name = method.getName();
if (!"selectPendingFairly".equals(name)
&& !"claimPending".equals(name)
&& !"renewLease".equals(name)
&& !"finishOwned".equals(name)) {
continue;
}
Select select = method.getAnnotation(Select.class);
Update update = method.getAnnotation(Update.class);
String sql = select == null
? String.join(" ", update.value())
: String.join(" ", select.value());
Assert.assertTrue(name + " 缺少批次运行状态门禁",
sql.contains("batch.status='RUNNING'"));
}
}
/**
* 验证失败项恢复同时锁定文件项与运行批次,阻止熔断后继续建任务。
*
* @throws Exception 映射方法不存在时抛出
*/
@Test
public void retryItemSqlShouldRequireRunningBatchLock()
throws Exception {
Method method = DocumentImportBatchItemMapper.class.getMethod(
"selectFailedForRetry",
BigInteger.class
);
Select select = method.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
Assert.assertTrue(sql.contains("item.status='FAILED'"));
Assert.assertTrue(sql.contains("batch.status='RUNNING'"));
Assert.assertTrue(sql.contains("FOR UPDATE"));
}
/**
* 验证批次中断后,迟到 worker 不能重新打开文件项或覆盖中断原因。
*
* @throws Exception 映射方法不存在时抛出
*/
@Test
public void itemTransitionSqlShouldFenceInterruptedBatch()
throws Exception {
Method method = DocumentImportBatchItemMapper.class.getMethod(
"transitionStatus",
BigInteger.class,
String.class,
String.class,
String.class,
String.class,
String.class,
boolean.class,
int.class,
Date.class
);
Update update = method.getAnnotation(Update.class);
String sql = String.join(" ", update.value());
Assert.assertTrue(sql.contains(
"INNER JOIN tb_document_import_batch batch"));
Assert.assertTrue(sql.contains("batch.status<>'INTERRUPTED'"));
}
/**
* 验证批次中断同步文档状态时兼容历史无效 JSON 扩展字段。
*
* @throws Exception 映射方法不存在时抛出
*/
@Test
public void interruptDocumentSqlShouldHandleInvalidOptions()
throws Exception {
Method method = DocumentImportTaskMapper.class.getMethod(
"interruptActiveDocuments",
BigInteger.class,
String.class,
String.class,
Date.class,
BigInteger.class
);
Update update = method.getAnnotation(Update.class);
String sql = String.join(" ", update.value());
Assert.assertTrue(sql.contains("JSON_VALID(document.options)"));
Assert.assertTrue(sql.contains("ELSE JSON_OBJECT()"));
}
/**
@@ -423,6 +801,7 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
BigInteger batchId = BigInteger.valueOf(61);
BigInteger itemId = BigInteger.valueOf(62);
DocumentImportTask task = new DocumentImportTask();
task.setId(BigInteger.valueOf(60));
task.setBatchId(batchId);
task.setBatchItemId(itemId);
DocumentImportBatchItem item = new DocumentImportBatchItem();
@@ -431,10 +810,13 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
DocumentImportBatchTracker tracker =
Mockito.mock(DocumentImportBatchTracker.class);
DocumentImportBatchCircuitBreaker circuitBreaker =
Mockito.mock(DocumentImportBatchCircuitBreaker.class);
Mockito.when(tracker.requireItem(itemId)).thenReturn(item);
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
setField(service, "documentImportBatchTracker", tracker);
setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
setField(service, "bulkProperties", new DocumentImportBulkProperties());
Method method = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod(
@@ -446,13 +828,20 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
);
method.setAccessible(true);
method.invoke(
service,
task,
DocumentImportBatchItemStage.PARSE,
"任务执行中断,请继续批次",
"execution_interrupted"
);
TransactionSynchronizationManager.setActualTransactionActive(true);
TransactionSynchronizationManager.initSynchronization();
try {
method.invoke(
service,
task,
DocumentImportBatchItemStage.PARSE,
"任务执行中断,请继续批次",
"execution_interrupted"
);
} finally {
TransactionSynchronizationManager.clearSynchronization();
TransactionSynchronizationManager.setActualTransactionActive(false);
}
Mockito.verify(tracker).transitionItem(
itemId,
@@ -463,7 +852,12 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
0,
"execution_interrupted"
);
Mockito.verify(tracker).markInterrupted(batchId);
Mockito.verify(circuitBreaker).interruptBatchInCurrentTransaction(
Mockito.eq(batchId),
Mockito.eq("execution_interrupted"),
Mockito.eq("任务执行中断,请继续批次"),
Mockito.any(IllegalStateException.class)
);
}
/**
@@ -657,6 +1051,38 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
Assert.assertEquals("invalid_parse_request", invalidRequest);
}
/**
* 验证 MinerU 不支持文件类型的响应经桥接异常包装后仍能安全归类。
*
* @throws Exception 反射调用异常
*/
@Test
public void resolveParseFailureShouldRecognizeWrappedUnsupportedFileType()
throws Exception {
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
Method codeMethod = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod("resolveParseFailureCode", Throwable.class);
Method messageMethod = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod("resolveParseFailureMessage", Throwable.class, String.class);
codeMethod.setAccessible(true);
messageMethod.setAccessible(true);
DocumentParseBridgeException error = DocumentParseBridgeException.taskFailed(
"异步解析任务失败",
new RuntimeException(
"MinerU request failed: path=/tasks, status=400, "
+ "body={\"detail\":\"Unsupported file type: doc\"}"
)
);
String code = (String) codeMethod.invoke(service, error);
String message = (String) messageMethod.invoke(service, error, code);
Assert.assertEquals("unsupported_document_source", code);
Assert.assertEquals("文档格式或来源不受解析服务支持,请检查文件后继续", message);
Assert.assertFalse(message.contains("doc"));
}
/**
* 验证未识别的系统异常仍会获得稳定错误码并允许后续人工重试。
*
@@ -677,27 +1103,144 @@ public class KnowledgeDocumentImportTaskAppServiceTest {
}
/**
* 验证未知系统异常和临时源读取失败允许人工重试,确定性输入错误除外
* 验证人工批次继续不受历史尝试次数限制
*
* @throws Exception 反射调用异常
* @throws Exception 反射调用或依赖注入异常
*/
@Test
public void parseRetryabilityShouldDefaultToRecoverable()
public void retryBatchItemShouldIgnoreHistoricalAttemptCount()
throws Exception {
KnowledgeDocumentImportTaskAppService service = Mockito.spy(
new KnowledgeDocumentImportTaskAppService());
DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
setField(service, "documentMapper", documentMapper);
DocumentImportBatchItem failed = new DocumentImportBatchItem();
failed.setId(BigInteger.valueOf(701));
failed.setKnowledgeId(BigInteger.valueOf(702));
failed.setDocumentId(BigInteger.valueOf(703));
failed.setStage(DocumentImportBatchItemStage.INDEX.name());
failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
failed.setAttemptCount(99);
tech.easyflow.ai.entity.Document document =
new tech.easyflow.ai.entity.Document();
document.setId(failed.getDocumentId());
document.setProcessStatus(DocumentProcessStatus.INDEX_FAILED.name());
Mockito.when(documentMapper.selectOneById(failed.getDocumentId()))
.thenReturn(document);
Mockito.doReturn(null).when(service).retryFailedTask(
Mockito.any(DocumentImportDtos.TaskRetryRequest.class));
Method method = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod("retryBatchItem", DocumentImportBatchItem.class);
method.setAccessible(true);
method.invoke(service, failed);
Mockito.verify(service).retryFailedTask(Mockito.argThat(request ->
failed.getKnowledgeId().equals(request.getKnowledgeId())
&& failed.getDocumentId().equals(request.getDocumentId())
));
}
/**
* 验证中断时形成的无文档失败项可以重新建档并创建解析任务。
*
* @throws Exception 反射调用或依赖注入异常
*/
@Test
public void retryBatchItemWithoutDocumentShouldRecreateImportTask()
throws Exception {
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
Method method = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod("isRetryableParseFailure", String.class);
method.setAccessible(true);
DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
DocumentImportTaskService taskService =
Mockito.mock(DocumentImportTaskService.class);
DocumentCollectionService knowledgeService =
Mockito.mock(DocumentCollectionService.class);
DocumentImportBatchTracker tracker =
Mockito.mock(DocumentImportBatchTracker.class);
DocumentImportParseTaskProducer producer =
Mockito.mock(DocumentImportParseTaskProducer.class);
BigInteger itemId = BigInteger.valueOf(711);
BigInteger batchId = BigInteger.valueOf(712);
BigInteger knowledgeId = BigInteger.valueOf(713);
BigInteger taskId = BigInteger.valueOf(714);
DocumentImportBatchItem failed = new DocumentImportBatchItem();
failed.setId(itemId);
failed.setBatchId(batchId);
failed.setKnowledgeId(knowledgeId);
failed.setFileName("recover.docx");
failed.setRelativePath("folder/recover.docx");
failed.setFilePath("/stored/recover.docx");
failed.setStage(DocumentImportBatchItemStage.UPLOAD.name());
failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
DocumentImportBatch batch = new DocumentImportBatch();
batch.setId(batchId);
batch.setKnowledgeId(knowledgeId);
batch.setImportMode(DocumentImportMode.AUTO.name());
batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
DocumentCollection knowledge = new DocumentCollection();
knowledge.setId(knowledgeId);
Mockito.when(tracker.requireBatch(batchId)).thenReturn(batch);
Mockito.when(knowledgeService.getById(knowledgeId))
.thenReturn(knowledge);
Mockito.when(taskService.save(Mockito.any(DocumentImportTask.class)))
.thenAnswer(invocation -> {
DocumentImportTask task = invocation.getArgument(0);
task.setId(taskId);
return true;
});
setField(service, "documentMapper", documentMapper);
setField(service, "documentImportTaskService", taskService);
setField(service, "knowledgeService", knowledgeService);
setField(service, "documentImportBatchTracker", tracker);
setField(service, "parseTaskProducer", producer);
Assert.assertTrue((Boolean) method.invoke(service, new Object[] {null}));
Assert.assertTrue((Boolean) method.invoke(
service, "document_source_unavailable"));
Assert.assertTrue((Boolean) method.invoke(service, "parse_failed"));
Assert.assertFalse((Boolean) method.invoke(
service, "unsupported_document_source"));
Assert.assertFalse((Boolean) method.invoke(
service, "invalid_parse_request"));
Method method = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod("retryBatchItem", DocumentImportBatchItem.class);
method.setAccessible(true);
method.invoke(service, failed);
ArgumentCaptor<tech.easyflow.ai.entity.Document> documentCaptor =
ArgumentCaptor.forClass(tech.easyflow.ai.entity.Document.class);
Mockito.verify(documentMapper).insert(documentCaptor.capture());
tech.easyflow.ai.entity.Document document = documentCaptor.getValue();
Assert.assertNotNull(document.getId());
Assert.assertEquals(knowledgeId, document.getCollectionId());
Assert.assertEquals(DocumentProcessStatus.PARSING.name(),
document.getProcessStatus());
ArgumentCaptor<DocumentImportTask> taskCaptor =
ArgumentCaptor.forClass(DocumentImportTask.class);
Mockito.verify(taskService).save(taskCaptor.capture());
Assert.assertEquals(taskId, taskCaptor.getValue().getId());
Assert.assertEquals(batchId, taskCaptor.getValue().getBatchId());
Assert.assertEquals(itemId, taskCaptor.getValue().getBatchItemId());
Assert.assertEquals(DocumentImportTaskPhase.PARSE.name(),
taskCaptor.getValue().getPhase());
Mockito.verify(tracker).bindDocument(itemId, document.getId());
Mockito.verify(producer).send(taskId);
}
/**
* 验证无文档失败项绑定 SQL 同时校验失败状态、空文档和运行批次。
*
* @throws Exception Mapper 方法不存在时抛出
*/
@Test
public void bindFailedDocumentSqlShouldFenceRecoveryState()
throws Exception {
Method method = DocumentImportBatchItemMapper.class.getMethod(
"bindFailedDocument",
BigInteger.class,
BigInteger.class,
Date.class
);
Update update = method.getAnnotation(Update.class);
String sql = String.join(" ", update.value());
Assert.assertTrue(sql.contains("item.status='FAILED'"));
Assert.assertTrue(sql.contains("item.document_id IS NULL"));
Assert.assertTrue(sql.contains("batch.status='RUNNING'"));
Assert.assertTrue(sql.contains("item.status='PENDING'"));
}
/**