perf: 收敛工作流状态与高 IO 节点开销
- 落地 Redis 版本状态、触发租约和定义缓存 - 优化数据批写、插件请求、文件下载与审计日志 - 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
package tech.easyflow.ai.easyagentsflow.event;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryLoopResultRepository;
|
||||
import com.easyagents.flow.core.chain.repository.LoopInputReference;
|
||||
import com.easyagents.flow.core.chain.repository.LoopResultReference;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||
import tech.easyflow.common.mq.config.MQProperties;
|
||||
import tech.easyflow.common.mq.core.MQDeadLetterService;
|
||||
import tech.easyflow.common.mq.core.MQMessage;
|
||||
import tech.easyflow.common.mq.core.MQProducer;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 工作流执行审计异步持久化测试。
|
||||
*/
|
||||
public class WorkflowExecutionAuditConsumerTest {
|
||||
|
||||
/**
|
||||
* 验证存在测试构造器时 Spring 仍能选择生产构造器创建 Bean。
|
||||
*/
|
||||
@Test
|
||||
public void shouldCreateProducerThroughSpringContext() {
|
||||
MQProducer mqProducer =
|
||||
Mockito.mock(MQProducer.class);
|
||||
MQDeadLetterService deadLetterService =
|
||||
Mockito.mock(
|
||||
MQDeadLetterService.class);
|
||||
try (AnnotationConfigApplicationContext context =
|
||||
new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(
|
||||
MQProducer.class,
|
||||
() -> mqProducer);
|
||||
context.registerBean(
|
||||
MQDeadLetterService.class,
|
||||
() -> deadLetterService);
|
||||
context.registerBean(
|
||||
WorkflowExecutionAuditProducer.class);
|
||||
context.refresh();
|
||||
|
||||
Assert.assertNotNull(
|
||||
context.getBean(
|
||||
WorkflowExecutionAuditProducer.class));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证生产者固定投递到单一有序分片。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPublishAuditEventToOrderedShard() {
|
||||
MQProducer mqProducer = Mockito.mock(MQProducer.class);
|
||||
MQDeadLetterService deadLetterService =
|
||||
Mockito.mock(MQDeadLetterService.class);
|
||||
WorkflowExecutionAuditProducer producer =
|
||||
new WorkflowExecutionAuditProducer(
|
||||
mqProducer, deadLetterService);
|
||||
try {
|
||||
WorkflowExecutionAuditEvent event = event(
|
||||
WorkflowExecutionAuditEvent.Type.CHAIN_STARTED,
|
||||
"instance-1:chain-started",
|
||||
"instance-1",
|
||||
new WorkflowExecResult(),
|
||||
null);
|
||||
|
||||
producer.send(event);
|
||||
|
||||
Mockito.verify(mqProducer).send(Mockito.argThat(message ->
|
||||
WorkflowExecutionAuditMqConstants.TOPIC.equals(
|
||||
message.getTopic())
|
||||
&& "instance-1:chain-started".equals(
|
||||
message.getMessageId())
|
||||
&& "instance-1".equals(message.getKey())
|
||||
&& message.getBody().contains(
|
||||
"CHAIN_STARTED")));
|
||||
} finally {
|
||||
producer.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证结束事件仅携带结束时间时仍可安全序列化投递。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPublishEndEventsWithoutStartTime() {
|
||||
MQProducer mqProducer =
|
||||
Mockito.mock(MQProducer.class);
|
||||
MQDeadLetterService deadLetterService =
|
||||
Mockito.mock(
|
||||
MQDeadLetterService.class);
|
||||
WorkflowExecutionAuditProducer producer =
|
||||
new WorkflowExecutionAuditProducer(
|
||||
mqProducer,
|
||||
deadLetterService);
|
||||
Mockito.when(mqProducer.send(
|
||||
Mockito.any()))
|
||||
.thenReturn("message-id");
|
||||
try {
|
||||
WorkflowExecStep step =
|
||||
new WorkflowExecStep();
|
||||
step.setExecKey("step-ended");
|
||||
step.setEndTime(new Date());
|
||||
WorkflowExecResult result =
|
||||
new WorkflowExecResult();
|
||||
result.setExecKey("instance-ended");
|
||||
result.setEndTime(new Date());
|
||||
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||
"step-ended:event",
|
||||
"instance-ended",
|
||||
null,
|
||||
step));
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||
"instance-ended:event",
|
||||
"instance-ended",
|
||||
result,
|
||||
null));
|
||||
|
||||
Mockito.verify(
|
||||
mqProducer,
|
||||
Mockito.times(2))
|
||||
.send(Mockito.argThat(message ->
|
||||
message.getBody() != null
|
||||
&& (message.getBody()
|
||||
.contains("NODE_ENDED")
|
||||
|| message.getBody()
|
||||
.contains("CHAIN_ENDED"))));
|
||||
} finally {
|
||||
producer.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证一个实例的毒消息退避不会阻塞其他发送 lane。
|
||||
*
|
||||
* @throws Exception 等待健康实例发送失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldIsolateRetryHeadBlockingAcrossLanes()
|
||||
throws Exception {
|
||||
MQProducer mqProducer =
|
||||
Mockito.mock(MQProducer.class);
|
||||
MQDeadLetterService deadLetterService =
|
||||
Mockito.mock(
|
||||
MQDeadLetterService.class);
|
||||
CountDownLatch healthySent =
|
||||
new CountDownLatch(1);
|
||||
Mockito.when(mqProducer.send(
|
||||
Mockito.any()))
|
||||
.thenAnswer(invocation -> {
|
||||
MQMessage message =
|
||||
invocation.getArgument(0);
|
||||
if ("instance-0".equals(
|
||||
message.getKey())) {
|
||||
throw new IllegalStateException(
|
||||
"poison");
|
||||
}
|
||||
healthySent.countDown();
|
||||
return "sent";
|
||||
});
|
||||
WorkflowExecutionAuditProducer producer =
|
||||
new WorkflowExecutionAuditProducer(
|
||||
mqProducer,
|
||||
deadLetterService,
|
||||
8,
|
||||
100,
|
||||
1024L * 1024L,
|
||||
8L * 1024L * 1024L,
|
||||
0L);
|
||||
try {
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||
"poison",
|
||||
"instance-0",
|
||||
null,
|
||||
new WorkflowExecStep()));
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||
"healthy",
|
||||
"instance-1",
|
||||
null,
|
||||
new WorkflowExecStep()));
|
||||
|
||||
Assert.assertTrue(
|
||||
healthySent.await(
|
||||
1L,
|
||||
TimeUnit.SECONDS));
|
||||
} finally {
|
||||
producer.close();
|
||||
}
|
||||
Mockito.verify(deadLetterService)
|
||||
.deadLetter(
|
||||
Mockito.argThat(message ->
|
||||
"poison".equals(
|
||||
message.getMessageId())),
|
||||
Mockito.contains("shutdown"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一实例失败恢复后仍按原事件顺序发送。
|
||||
*
|
||||
* @throws Exception 等待重试发送失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveOrderWithinAuditLane()
|
||||
throws Exception {
|
||||
MQProducer mqProducer =
|
||||
Mockito.mock(MQProducer.class);
|
||||
MQDeadLetterService deadLetterService =
|
||||
Mockito.mock(
|
||||
MQDeadLetterService.class);
|
||||
AtomicInteger firstAttempts =
|
||||
new AtomicInteger();
|
||||
CountDownLatch sent =
|
||||
new CountDownLatch(2);
|
||||
List<String> order =
|
||||
Collections.synchronizedList(
|
||||
new ArrayList<>());
|
||||
Mockito.when(mqProducer.send(
|
||||
Mockito.any()))
|
||||
.thenAnswer(invocation -> {
|
||||
MQMessage message =
|
||||
invocation.getArgument(0);
|
||||
if ("first".equals(
|
||||
message.getMessageId())
|
||||
&& firstAttempts
|
||||
.getAndIncrement() == 0) {
|
||||
throw new IllegalStateException(
|
||||
"temporary");
|
||||
}
|
||||
order.add(
|
||||
message.getMessageId());
|
||||
sent.countDown();
|
||||
return "sent";
|
||||
});
|
||||
WorkflowExecutionAuditProducer producer =
|
||||
new WorkflowExecutionAuditProducer(
|
||||
mqProducer,
|
||||
deadLetterService,
|
||||
2,
|
||||
100,
|
||||
1024L * 1024L,
|
||||
8L * 1024L * 1024L,
|
||||
1000L);
|
||||
try {
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||
"first",
|
||||
"same-instance",
|
||||
null,
|
||||
new WorkflowExecStep()));
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||
"second",
|
||||
"same-instance",
|
||||
null,
|
||||
new WorkflowExecStep()));
|
||||
|
||||
Assert.assertTrue(
|
||||
sent.await(
|
||||
2L,
|
||||
TimeUnit.SECONDS));
|
||||
Assert.assertEquals(
|
||||
List.of("first", "second"),
|
||||
order);
|
||||
} finally {
|
||||
producer.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证超出单条字节预算的审计消息直接进入死信并显式失败。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectOversizedAuditMessage() {
|
||||
MQProducer mqProducer =
|
||||
Mockito.mock(MQProducer.class);
|
||||
MQDeadLetterService deadLetterService =
|
||||
Mockito.mock(
|
||||
MQDeadLetterService.class);
|
||||
WorkflowExecutionAuditProducer producer =
|
||||
new WorkflowExecutionAuditProducer(
|
||||
mqProducer,
|
||||
deadLetterService,
|
||||
1,
|
||||
10,
|
||||
512L,
|
||||
2048L,
|
||||
0L);
|
||||
WorkflowExecResult result =
|
||||
new WorkflowExecResult();
|
||||
result.setOutput(
|
||||
"x".repeat(1024));
|
||||
try {
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||
"oversized",
|
||||
"instance",
|
||||
result,
|
||||
null));
|
||||
Assert.fail(
|
||||
"oversized audit message should fail");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
Assert.assertTrue(
|
||||
expected.getMessage()
|
||||
.contains("byte limit"));
|
||||
} finally {
|
||||
producer.close();
|
||||
}
|
||||
Mockito.verify(
|
||||
deadLetterService)
|
||||
.deadLetter(
|
||||
Mockito.argThat(message ->
|
||||
"oversized".equals(
|
||||
message.getMessageId())),
|
||||
Mockito.contains("byte limit"));
|
||||
Mockito.verifyNoInteractions(
|
||||
mqProducer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证关闭期间仍在直发的失败消息会转入死信且不会重新形成孤儿积压。
|
||||
*
|
||||
* @throws Exception 并发关闭、等待或反射读取失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldNotEnqueueAfterConcurrentClose()
|
||||
throws Exception {
|
||||
MQProducer mqProducer =
|
||||
Mockito.mock(MQProducer.class);
|
||||
MQDeadLetterService deadLetterService =
|
||||
Mockito.mock(
|
||||
MQDeadLetterService.class);
|
||||
CountDownLatch sending =
|
||||
new CountDownLatch(1);
|
||||
CountDownLatch releaseSend =
|
||||
new CountDownLatch(1);
|
||||
Mockito.when(mqProducer.send(
|
||||
Mockito.any()))
|
||||
.thenAnswer(invocation -> {
|
||||
sending.countDown();
|
||||
releaseSend.await(
|
||||
2L,
|
||||
TimeUnit.SECONDS);
|
||||
throw new IllegalStateException(
|
||||
"send failed during close");
|
||||
});
|
||||
WorkflowExecutionAuditProducer producer =
|
||||
new WorkflowExecutionAuditProducer(
|
||||
mqProducer,
|
||||
deadLetterService,
|
||||
1,
|
||||
100,
|
||||
1024L * 1024L,
|
||||
8L * 1024L * 1024L,
|
||||
0L);
|
||||
ExecutorService callers =
|
||||
Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> sender =
|
||||
callers.submit(() -> {
|
||||
try {
|
||||
producer.send(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||
"closing",
|
||||
"instance",
|
||||
null,
|
||||
new WorkflowExecStep()));
|
||||
Assert.fail(
|
||||
"send should report concurrent close");
|
||||
} catch (IllegalStateException expected) {
|
||||
Assert.assertTrue(
|
||||
expected.getMessage()
|
||||
.contains("closed"));
|
||||
}
|
||||
});
|
||||
Assert.assertTrue(
|
||||
sending.await(
|
||||
1L,
|
||||
TimeUnit.SECONDS));
|
||||
Future<?> closer =
|
||||
callers.submit(
|
||||
producer::close);
|
||||
closer.get(
|
||||
1L,
|
||||
TimeUnit.SECONDS);
|
||||
releaseSend.countDown();
|
||||
sender.get(
|
||||
2L,
|
||||
TimeUnit.SECONDS);
|
||||
|
||||
Field backlogCountField =
|
||||
WorkflowExecutionAuditProducer.class
|
||||
.getDeclaredField(
|
||||
"backlogCount");
|
||||
backlogCountField.setAccessible(true);
|
||||
Assert.assertEquals(
|
||||
0,
|
||||
backlogCountField.getInt(
|
||||
producer));
|
||||
Mockito.verify(deadLetterService)
|
||||
.deadLetter(
|
||||
Mockito.argThat(message ->
|
||||
"closing".equals(
|
||||
message.getMessageId())),
|
||||
Mockito.contains(
|
||||
"closed during send"));
|
||||
} finally {
|
||||
releaseSend.countDown();
|
||||
producer.close();
|
||||
callers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证启动、节点开始、节点结束和流程结束事件按顺序幂等落库。
|
||||
*/
|
||||
@Test
|
||||
public void shouldApplyOrderedExecutionAuditEvents() {
|
||||
WorkflowExecResultService resultService =
|
||||
Mockito.mock(WorkflowExecResultService.class);
|
||||
WorkflowExecStepService stepService =
|
||||
Mockito.mock(WorkflowExecStepService.class);
|
||||
InMemoryLoopResultRepository loopRepository =
|
||||
new InMemoryLoopResultRepository();
|
||||
WorkflowExecutionAuditConsumer consumer =
|
||||
new WorkflowExecutionAuditConsumer(
|
||||
resultService,
|
||||
stepService,
|
||||
new MQProperties(),
|
||||
loopRepository);
|
||||
|
||||
WorkflowExecResult persistedResult = new WorkflowExecResult();
|
||||
persistedResult.setId(BigInteger.ONE);
|
||||
persistedResult.setExecKey("instance-1");
|
||||
Mockito.when(resultService.getByExecKey("instance-1"))
|
||||
.thenReturn(persistedResult);
|
||||
Mockito.when(resultService.updateByExecKey(Mockito.any()))
|
||||
.thenReturn(1);
|
||||
Mockito.when(stepService.updateByExecKey(Mockito.any()))
|
||||
.thenReturn(1);
|
||||
|
||||
WorkflowExecResult startRecord = new WorkflowExecResult();
|
||||
startRecord.setExecKey("instance-1");
|
||||
startRecord.setStatus(1);
|
||||
WorkflowExecStep startStep = new WorkflowExecStep();
|
||||
startStep.setExecKey("step-1");
|
||||
startStep.setNodeId("node-1");
|
||||
startStep.setNodeName("node");
|
||||
startStep.setStatus(1);
|
||||
WorkflowExecStep endStep = new WorkflowExecStep();
|
||||
endStep.setExecKey("step-1");
|
||||
endStep.setStatus(2);
|
||||
endStep.setOutput("{\"value\":1}");
|
||||
WorkflowExecResult endRecord = new WorkflowExecResult();
|
||||
endRecord.setExecKey("instance-1");
|
||||
endRecord.setStatus(2);
|
||||
endRecord.setOutput("{\"value\":1}");
|
||||
|
||||
consumer.handle(List.of(
|
||||
message(event(WorkflowExecutionAuditEvent.Type.CHAIN_STARTED,
|
||||
"start", "instance-1", startRecord, null)),
|
||||
message(event(WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||
"node-start", "instance-1", null, startStep)),
|
||||
message(event(WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||
"node-end", "instance-1", null, endStep)),
|
||||
message(event(WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||
"end", "instance-1", endRecord, null))
|
||||
));
|
||||
|
||||
Mockito.verify(resultService).save(Mockito.argThat(record ->
|
||||
"instance-1".equals(record.getExecKey())
|
||||
&& Integer.valueOf(1).equals(record.getStatus())));
|
||||
Mockito.verify(stepService).save(Mockito.argThat(step ->
|
||||
"step-1".equals(step.getExecKey())
|
||||
&& BigInteger.ONE.equals(step.getRecordId())));
|
||||
Mockito.verify(stepService).updateByExecKey(
|
||||
Mockito.argThat(step ->
|
||||
"step-1".equals(step.getExecKey())
|
||||
&& "{\"value\":1}".equals(
|
||||
step.getOutput())));
|
||||
Mockito.verify(resultService).updateByExecKey(
|
||||
Mockito.argThat(record ->
|
||||
"instance-1".equals(record.getExecKey())
|
||||
&& "{\"value\":1}".equals(
|
||||
record.getOutput())));
|
||||
Mockito.verify(stepService, Mockito.never())
|
||||
.getByExecKey(Mockito.anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证大型结果引用在审计消费线程还原,持久记录仍保持完整 JSON。
|
||||
*/
|
||||
@Test
|
||||
public void shouldResolveLargeReferenceInAuditConsumer() {
|
||||
WorkflowExecResultService resultService =
|
||||
Mockito.mock(
|
||||
WorkflowExecResultService.class);
|
||||
WorkflowExecStepService stepService =
|
||||
Mockito.mock(
|
||||
WorkflowExecStepService.class);
|
||||
InMemoryLoopResultRepository loopRepository =
|
||||
new InMemoryLoopResultRepository();
|
||||
String resultId = "instance:dataset:rows";
|
||||
loopRepository.storeInput(
|
||||
resultId, List.of(1, 2, 3));
|
||||
WorkflowExecutionAuditConsumer consumer =
|
||||
new WorkflowExecutionAuditConsumer(
|
||||
resultService,
|
||||
stepService,
|
||||
new MQProperties(),
|
||||
loopRepository);
|
||||
Mockito.when(stepService.updateByExecKey(
|
||||
Mockito.any()))
|
||||
.thenReturn(1);
|
||||
WorkflowExecStep incoming =
|
||||
new WorkflowExecStep();
|
||||
incoming.setExecKey("step-reference");
|
||||
incoming.setOutput(JSON.toJSONString(
|
||||
Map.of(
|
||||
"data",
|
||||
new LoopInputReference(
|
||||
resultId, 3))));
|
||||
|
||||
consumer.handle(List.of(message(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||
"node-reference",
|
||||
"instance",
|
||||
null,
|
||||
incoming))));
|
||||
|
||||
Mockito.verify(stepService).updateByExecKey(
|
||||
Mockito.argThat(step ->
|
||||
"{\"data\":[1,2,3]}"
|
||||
.equals(step.getOutput())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点启动输入引用在审计消费者中还原后再保存。
|
||||
*/
|
||||
@Test
|
||||
public void shouldResolveLargeInputReferenceWhenCreatingStep() {
|
||||
WorkflowExecResultService resultService =
|
||||
Mockito.mock(
|
||||
WorkflowExecResultService.class);
|
||||
WorkflowExecStepService stepService =
|
||||
Mockito.mock(
|
||||
WorkflowExecStepService.class);
|
||||
InMemoryLoopResultRepository loopRepository =
|
||||
new InMemoryLoopResultRepository();
|
||||
String resultId = "instance:dataset:input";
|
||||
loopRepository.storeInput(
|
||||
resultId, List.of(1, 2, 3));
|
||||
WorkflowExecutionAuditConsumer consumer =
|
||||
new WorkflowExecutionAuditConsumer(
|
||||
resultService,
|
||||
stepService,
|
||||
new MQProperties(),
|
||||
loopRepository);
|
||||
WorkflowExecResult record =
|
||||
new WorkflowExecResult();
|
||||
record.setId(BigInteger.ONE);
|
||||
Mockito.when(resultService.getByExecKey(
|
||||
"instance"))
|
||||
.thenReturn(record);
|
||||
WorkflowExecStep incoming =
|
||||
new WorkflowExecStep();
|
||||
incoming.setExecKey("step-input");
|
||||
incoming.setInput(JSON.toJSONString(
|
||||
Map.of(
|
||||
"items",
|
||||
new LoopInputReference(
|
||||
resultId, 3))));
|
||||
|
||||
consumer.handle(List.of(message(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||
"node-input",
|
||||
"instance",
|
||||
null,
|
||||
incoming))));
|
||||
|
||||
Mockito.verify(stepService).save(
|
||||
Mockito.argThat(step ->
|
||||
BigInteger.ONE.equals(
|
||||
step.getRecordId())
|
||||
&& "{\"items\":[1,2,3]}"
|
||||
.equals(step.getInput())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点结束与流程结束审计均还原循环累计输出。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldResolveLoopResultReferenceForEndedAudits() {
|
||||
WorkflowExecResultService resultService =
|
||||
Mockito.mock(
|
||||
WorkflowExecResultService.class);
|
||||
WorkflowExecStepService stepService =
|
||||
Mockito.mock(
|
||||
WorkflowExecStepService.class);
|
||||
InMemoryLoopResultRepository loopRepository =
|
||||
new InMemoryLoopResultRepository();
|
||||
String resultId = "instance:loop:result";
|
||||
loopRepository.append(
|
||||
resultId,
|
||||
0,
|
||||
Map.of("answer", "first"));
|
||||
loopRepository.append(
|
||||
resultId,
|
||||
1,
|
||||
Map.of("answer", "second"));
|
||||
WorkflowExecutionAuditConsumer consumer =
|
||||
new WorkflowExecutionAuditConsumer(
|
||||
resultService,
|
||||
stepService,
|
||||
new MQProperties(),
|
||||
loopRepository);
|
||||
Mockito.when(stepService.updateByExecKey(
|
||||
Mockito.any()))
|
||||
.thenReturn(1);
|
||||
Mockito.when(resultService.updateByExecKey(
|
||||
Mockito.any()))
|
||||
.thenReturn(1);
|
||||
Map<String, Object> referenceOutput =
|
||||
Map.of(
|
||||
"answers",
|
||||
new LoopResultReference(
|
||||
resultId,
|
||||
2,
|
||||
"answer"));
|
||||
WorkflowExecStep incomingStep =
|
||||
new WorkflowExecStep();
|
||||
incomingStep.setExecKey("step-loop");
|
||||
incomingStep.setOutput(
|
||||
JSON.toJSONString(
|
||||
referenceOutput));
|
||||
WorkflowExecResult incomingResult =
|
||||
new WorkflowExecResult();
|
||||
incomingResult.setExecKey(
|
||||
"instance-loop");
|
||||
incomingResult.setOutput(
|
||||
JSON.toJSONString(
|
||||
referenceOutput));
|
||||
|
||||
consumer.handle(List.of(
|
||||
message(event(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||
"node-loop-ended",
|
||||
"instance-loop",
|
||||
null,
|
||||
incomingStep)),
|
||||
message(event(
|
||||
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||
"chain-loop-ended",
|
||||
"instance-loop",
|
||||
incomingResult,
|
||||
null))));
|
||||
|
||||
org.mockito.ArgumentCaptor<WorkflowExecStep>
|
||||
stepCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
WorkflowExecStep.class);
|
||||
org.mockito.ArgumentCaptor<WorkflowExecResult>
|
||||
resultCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
WorkflowExecResult.class);
|
||||
Mockito.verify(stepService)
|
||||
.updateByExecKey(stepCaptor.capture());
|
||||
Mockito.verify(resultService)
|
||||
.updateByExecKey(resultCaptor.capture());
|
||||
Map<String, Object> stepOutput =
|
||||
JSON.parseObject(
|
||||
stepCaptor.getValue().getOutput(),
|
||||
Map.class);
|
||||
Map<String, Object> resultOutput =
|
||||
JSON.parseObject(
|
||||
resultCaptor.getValue().getOutput(),
|
||||
Map.class);
|
||||
Assert.assertEquals(
|
||||
List.of("first", "second"),
|
||||
stepOutput.get("answers"));
|
||||
Assert.assertEquals(
|
||||
List.of("first", "second"),
|
||||
resultOutput.get("answers"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造审计事件。
|
||||
*
|
||||
* @param type 事件类型
|
||||
* @param eventId 事件 ID
|
||||
* @param instanceId 实例 ID
|
||||
* @param result 工作流记录
|
||||
* @param step 节点步骤
|
||||
* @return 审计事件
|
||||
*/
|
||||
private WorkflowExecutionAuditEvent event(WorkflowExecutionAuditEvent.Type type,
|
||||
String eventId,
|
||||
String instanceId,
|
||||
WorkflowExecResult result,
|
||||
WorkflowExecStep step) {
|
||||
WorkflowExecutionAuditEvent event = new WorkflowExecutionAuditEvent();
|
||||
event.setType(type);
|
||||
event.setEventId(eventId);
|
||||
event.setInstanceId(instanceId);
|
||||
event.setOccurredAt(new Date());
|
||||
event.setResult(result);
|
||||
event.setStep(step);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将审计事件包装为通用 MQ 消息。
|
||||
*
|
||||
* @param event 审计事件
|
||||
* @return MQ 消息
|
||||
*/
|
||||
private MQMessage message(WorkflowExecutionAuditEvent event) {
|
||||
MQMessage message = new MQMessage();
|
||||
message.setMessageId(event.getEventId());
|
||||
message.setBody(JSON.toJSONString(event));
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package tech.easyflow.ai.easyagentsflow.listener;
|
||||
|
||||
import com.easyagents.flow.core.chain.Chain;
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import com.easyagents.flow.core.chain.event.NodeEndEvent;
|
||||
import com.easyagents.flow.core.chain.event.NodeStartEvent;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
|
||||
import com.easyagents.flow.core.node.StartNode;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
|
||||
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* {@link ChainEventListenerForSave} 节点审计归属回归测试。
|
||||
*/
|
||||
public class ChainEventListenerForSaveTest {
|
||||
|
||||
/**
|
||||
* 验证 parent-linked 节点开始与结束事件使用同一顶级实例顺序键。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldUseSameRootInstanceForNodeStartAndEnd()
|
||||
throws Exception {
|
||||
String suffix =
|
||||
UUID.randomUUID().toString();
|
||||
String rootId =
|
||||
"root-" + suffix;
|
||||
String childId =
|
||||
"child-" + suffix;
|
||||
InMemoryChainStateRepository repository =
|
||||
new InMemoryChainStateRepository();
|
||||
ChainState root =
|
||||
repository.create(rootId);
|
||||
ChainState child =
|
||||
repository.create(childId);
|
||||
child.setParentInstanceId(rootId);
|
||||
child.setAuditInstanceId(null);
|
||||
|
||||
ChainDefinition definition =
|
||||
new ChainDefinition();
|
||||
definition.setId("1");
|
||||
StartNode node =
|
||||
new StartNode();
|
||||
node.setId("node");
|
||||
definition.addNode(node);
|
||||
Chain chain =
|
||||
new Chain(definition, childId);
|
||||
chain.setChainStateRepository(repository);
|
||||
WorkflowExecutionAuditProducer producer =
|
||||
Mockito.mock(
|
||||
WorkflowExecutionAuditProducer.class);
|
||||
ChainEventListenerForSave listener =
|
||||
new ChainEventListenerForSave();
|
||||
Field producerField =
|
||||
ChainEventListenerForSave.class
|
||||
.getDeclaredField("auditProducer");
|
||||
producerField.setAccessible(true);
|
||||
producerField.set(listener, producer);
|
||||
|
||||
listener.onEvent(
|
||||
new NodeStartEvent(
|
||||
chain,
|
||||
node,
|
||||
"attempt",
|
||||
NodeStatus.RUNNING,
|
||||
chain.getAuditInstanceId()),
|
||||
chain);
|
||||
listener.onEvent(
|
||||
new NodeEndEvent(
|
||||
chain,
|
||||
node,
|
||||
Map.of("value", "ok"),
|
||||
null,
|
||||
NodeStatus.SUCCEEDED,
|
||||
"attempt"),
|
||||
chain);
|
||||
|
||||
ArgumentCaptor<WorkflowExecutionAuditEvent> captor =
|
||||
ArgumentCaptor.forClass(
|
||||
WorkflowExecutionAuditEvent.class);
|
||||
Mockito.verify(
|
||||
producer,
|
||||
Mockito.times(2))
|
||||
.send(captor.capture());
|
||||
List<WorkflowExecutionAuditEvent> events =
|
||||
captor.getAllValues();
|
||||
Assert.assertEquals(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||
events.get(0).getType());
|
||||
Assert.assertEquals(
|
||||
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||
events.get(1).getType());
|
||||
Assert.assertEquals(
|
||||
rootId,
|
||||
events.get(0).getInstanceId());
|
||||
Assert.assertEquals(
|
||||
rootId,
|
||||
events.get(1).getInstanceId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.alicp.jetcache.Cache;
|
||||
import com.alicp.jetcache.CacheException;
|
||||
import com.alicp.jetcache.CacheResult;
|
||||
import com.alicp.jetcache.CacheResultCode;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
|
||||
/**
|
||||
* {@link BaseRepository} 缓存操作语义回归测试。
|
||||
*/
|
||||
public class BaseRepositoryTest {
|
||||
|
||||
/**
|
||||
* 验证删除不存在的缓存键按幂等成功处理。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void removeCacheShouldAcceptMissingKey() throws Exception {
|
||||
TestRepository repository = repository(
|
||||
new CacheResult(CacheResultCode.NOT_EXISTS, null));
|
||||
|
||||
repository.remove("missing-key");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证真实删除错误仍会向上抛出。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void removeCacheShouldRejectOperationFailure() throws Exception {
|
||||
TestRepository repository = repository(
|
||||
new CacheResult(CacheResultCode.FAIL, "redis unavailable"));
|
||||
|
||||
try {
|
||||
repository.remove("failed-key");
|
||||
Assert.fail("cache failure should be propagated");
|
||||
} catch (CacheException expected) {
|
||||
Assert.assertTrue(expected.getMessage().contains("failed-key"));
|
||||
Assert.assertTrue(expected.getMessage().contains("redis unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建注入指定删除结果的测试仓储。
|
||||
*
|
||||
* @param removeResult 删除操作结果
|
||||
* @return 测试仓储
|
||||
* @throws Exception 反射注入失败时抛出
|
||||
*/
|
||||
private TestRepository repository(CacheResult removeResult) throws Exception {
|
||||
Cache<String, Object> cache = cache(removeResult);
|
||||
TestRepository repository = new TestRepository();
|
||||
Field field = BaseRepository.class.getDeclaredField("cache");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, cache);
|
||||
return repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建只支持删除操作的 JetCache 代理。
|
||||
*
|
||||
* @param removeResult 删除操作结果
|
||||
* @return JetCache 测试代理
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Cache<String, Object> cache(CacheResult removeResult) {
|
||||
return (Cache<String, Object>) Proxy.newProxyInstance(
|
||||
Cache.class.getClassLoader(),
|
||||
new Class<?>[]{Cache.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("REMOVE".equals(method.getName())) {
|
||||
return removeResult;
|
||||
}
|
||||
throw new UnsupportedOperationException(
|
||||
"unsupported cache method: " + method.getName());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露受保护缓存删除能力的测试仓储。
|
||||
*/
|
||||
private static final class TestRepository extends BaseRepository {
|
||||
|
||||
/**
|
||||
* 删除指定缓存键。
|
||||
*
|
||||
* @param key 缓存键
|
||||
*/
|
||||
private void remove(String key) {
|
||||
removeCache(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.Node;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.node.BaseNode;
|
||||
import com.easyagents.flow.core.node.EndNode;
|
||||
import com.easyagents.flow.core.node.HttpNode;
|
||||
import com.easyagents.flow.core.node.LlmNode;
|
||||
import com.easyagents.flow.core.node.LoopNode;
|
||||
import com.easyagents.flow.core.node.StartNode;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.node.ConditionNode;
|
||||
import tech.easyflow.ai.node.DocNode;
|
||||
import tech.easyflow.ai.node.DownloadNode;
|
||||
import tech.easyflow.ai.node.MakeFileNode;
|
||||
import tech.easyflow.ai.node.PluginToolNode;
|
||||
import tech.easyflow.ai.node.SaveDatasetNode;
|
||||
import tech.easyflow.ai.node.SearchDatasetNode;
|
||||
import tech.easyflow.ai.node.WorkflowNode;
|
||||
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.io.ObjectStreamClass;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作流定义快照 Java 序列化兼容约束测试。
|
||||
*/
|
||||
public class ChainDefinitionSnapshotSerializationTest {
|
||||
|
||||
/**
|
||||
* 验证全部运行时业务节点对象图可完成定义快照往返。
|
||||
*
|
||||
* @throws Exception 序列化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldRoundTripAllRuntimeNodeTypes()
|
||||
throws Exception {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
definition.setId("snapshot-all-node-types");
|
||||
List<Node> nodes = List.of(
|
||||
node(new StartNode(), "start"),
|
||||
node(new EndNode(), "end"),
|
||||
node(new HttpNode(), "http"),
|
||||
node(new LlmNode(), "llm"),
|
||||
node(new LoopNode(), "loop"),
|
||||
node(new PluginToolNode(), "plugin"),
|
||||
node(new DownloadNode(), "download"),
|
||||
node(new WorkflowNode(), "workflow"),
|
||||
node(new DocNode(), "doc"),
|
||||
datasetNode(new SearchDatasetNode(), "search"),
|
||||
datasetNode(new SaveDatasetNode(), "save"),
|
||||
conditionNode(),
|
||||
node(new MakeFileNode(), "make-file"));
|
||||
nodes.forEach(definition::addNode);
|
||||
|
||||
byte[] bytes;
|
||||
try (ByteArrayOutputStream output =
|
||||
new ByteArrayOutputStream();
|
||||
ObjectOutputStream objectOutput =
|
||||
new ObjectOutputStream(output)) {
|
||||
objectOutput.writeObject(definition);
|
||||
objectOutput.flush();
|
||||
bytes = output.toByteArray();
|
||||
}
|
||||
ChainDefinition restored;
|
||||
try (ObjectInputStream input =
|
||||
new ObjectInputStream(
|
||||
new ByteArrayInputStream(bytes))) {
|
||||
restored = (ChainDefinition) input.readObject();
|
||||
}
|
||||
|
||||
Assert.assertEquals(
|
||||
definition.getId(), restored.getId());
|
||||
Assert.assertEquals(
|
||||
nodes.size(), restored.getNodes().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证定义对象图关键类使用显式稳定 UID,防止新增方法导致默认 UID 漂移。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepStableSerialVersionUids() {
|
||||
List<Class<?>> stableTypes = List.of(
|
||||
Node.class,
|
||||
BaseNode.class,
|
||||
Parameter.class,
|
||||
StartNode.class,
|
||||
EndNode.class,
|
||||
HttpNode.class,
|
||||
LlmNode.class,
|
||||
LoopNode.class,
|
||||
PluginToolNode.class,
|
||||
DownloadNode.class,
|
||||
WorkflowNode.class,
|
||||
DocNode.class,
|
||||
SearchDatasetNode.class,
|
||||
SaveDatasetNode.class,
|
||||
ConditionNode.class,
|
||||
ConditionNode.ConditionBranch.class,
|
||||
ConditionNode.ConditionRule.class,
|
||||
MakeFileNode.class,
|
||||
DatasetRef.class);
|
||||
for (Class<?> type : stableTypes) {
|
||||
Assert.assertEquals(
|
||||
"unstable serialVersionUID: "
|
||||
+ type.getName(),
|
||||
1L,
|
||||
ObjectStreamClass.lookup(type)
|
||||
.getSerialVersionUID());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置测试节点 ID。
|
||||
*
|
||||
* @param node 节点
|
||||
* @param id 节点 ID
|
||||
* @return 原节点
|
||||
*/
|
||||
private <T extends Node> T node(T node, String id) {
|
||||
node.setId(id);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置带数据集引用的节点。
|
||||
*
|
||||
* @param node 数据集节点
|
||||
* @param id 节点 ID
|
||||
* @return 原节点
|
||||
*/
|
||||
private <T extends Node> T datasetNode(
|
||||
T node, String id) {
|
||||
DatasetRef ref = new DatasetRef();
|
||||
ref.setTableName("dataset_table");
|
||||
if (node instanceof SearchDatasetNode) {
|
||||
((SearchDatasetNode) node).setDatasetRef(ref);
|
||||
} else {
|
||||
((SaveDatasetNode) node).setDatasetRef(ref);
|
||||
}
|
||||
return node(node, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带完整嵌套规则对象图的条件节点。
|
||||
*
|
||||
* @return 条件节点
|
||||
*/
|
||||
private ConditionNode conditionNode() {
|
||||
ConditionNode.ConditionRule rule =
|
||||
new ConditionNode.ConditionRule();
|
||||
rule.setId("rule");
|
||||
ConditionNode.ConditionBranch branch =
|
||||
new ConditionNode.ConditionBranch();
|
||||
branch.setId("branch");
|
||||
branch.setRules(List.of(rule));
|
||||
ConditionNode node = node(
|
||||
new ConditionNode(), "condition");
|
||||
node.setBranches(List.of(branch));
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -5,36 +5,52 @@ import com.alicp.jetcache.CacheException;
|
||||
import com.alicp.jetcache.CacheGetResult;
|
||||
import com.alicp.jetcache.CacheResult;
|
||||
import com.alicp.jetcache.CacheResultCode;
|
||||
import com.alicp.jetcache.CacheValueHolder;
|
||||
import com.alicp.jetcache.support.CacheEncodeException;
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.repository.ChainLock;
|
||||
import com.easyagents.flow.core.chain.repository.ChainStateField;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.cache.VersionedObjectStore;
|
||||
import tech.easyflow.common.cache.VersionedFields;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.Duration;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* {@link ChainStateRepositoryImpl} 缓存异常处理回归测试。
|
||||
* {@link ChainStateRepositoryImpl} 缓存迁移和版本提交回归测试。
|
||||
*/
|
||||
public class ChainStateRepositoryImplTest {
|
||||
|
||||
/**
|
||||
* 验证缓存解码失败时抛出异常且不创建空工作流状态。
|
||||
* 验证旧缓存解码失败时抛出异常且不创建空工作流状态。
|
||||
*
|
||||
* @throws Exception 缓存依赖注入失败时抛出
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void loadShouldFailWithoutOverwritingStateWhenCacheDecodeFails() throws Exception {
|
||||
String instanceId = "decode-failed-instance";
|
||||
CacheGetResult<Object> failure = new CacheGetResult<>(new CacheEncodeException(
|
||||
"decode error",
|
||||
new ClassNotFoundException("com.alicp.jetcache.CacheValueHolder")
|
||||
"decode error",
|
||||
new ClassNotFoundException("com.alicp.jetcache.CacheValueHolder")
|
||||
));
|
||||
RecordingCache cache = new RecordingCache(failure, CacheResult.SUCCESS_WITHOUT_MSG);
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache());
|
||||
RecordingCache cache = new RecordingCache(failure);
|
||||
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||
|
||||
try {
|
||||
repository.load(instanceId);
|
||||
@@ -44,142 +60,448 @@ public class ChainStateRepositoryImplTest {
|
||||
Assert.assertTrue(expected.getMessage().contains(instanceId));
|
||||
}
|
||||
|
||||
Assert.assertEquals(0, cache.getPutCount());
|
||||
Assert.assertEquals(0, stateStore.getCreateCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缓存未命中时创建并持久化新的工作流状态。
|
||||
* 验证新实例通过版本对象存储显式创建。
|
||||
*
|
||||
* @throws Exception 缓存依赖注入失败时抛出
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void loadShouldCreateStateWhenCacheDoesNotExist() throws Exception {
|
||||
public void createShouldPersistStateWhenStateDoesNotExist() throws Exception {
|
||||
String instanceId = "new-instance";
|
||||
RecordingCache cache = new RecordingCache(
|
||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
|
||||
CacheResult.SUCCESS_WITHOUT_MSG
|
||||
);
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache());
|
||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||
|
||||
ChainState state = repository.load(instanceId);
|
||||
ChainState state = repository.create(instanceId);
|
||||
|
||||
Assert.assertEquals(instanceId, state.getInstanceId());
|
||||
Assert.assertEquals(1, cache.getPutCount());
|
||||
Assert.assertSame(state, cache.getLastPutValue());
|
||||
Assert.assertEquals(1, stateStore.getCreateCount());
|
||||
Assert.assertEquals(
|
||||
instanceId,
|
||||
stateStore.getLastCreatedFields().get(ChainStateField.INSTANCE_ID.name()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缓存写入失败时不会返回未持久化的工作流状态。
|
||||
* 验证旧 JetCache 状态首次读取后迁移到版本对象存储。
|
||||
*
|
||||
* @throws Exception 缓存依赖注入失败时抛出
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void loadShouldFailWhenNewStateCannotBePersisted() throws Exception {
|
||||
public void loadShouldMigrateLegacyStateOnce() throws Exception {
|
||||
ChainState legacy = new ChainState();
|
||||
legacy.setInstanceId("legacy-instance");
|
||||
legacy.setVersion(7L);
|
||||
legacy.setStatus(ChainStatus.SUCCEEDED);
|
||||
RecordingCache cache = new RecordingCache(
|
||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
|
||||
new CacheResult(new IllegalStateException("redis unavailable"))
|
||||
);
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache());
|
||||
new CacheGetResult<>(
|
||||
CacheResultCode.SUCCESS,
|
||||
null,
|
||||
new CacheValueHolder<>(legacy, Long.MAX_VALUE)));
|
||||
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||
|
||||
try {
|
||||
repository.load("write-failed-instance");
|
||||
Assert.fail("cache write failure should be propagated");
|
||||
} catch (CacheException expected) {
|
||||
Assert.assertTrue(expected.getMessage().contains("工作流状态缓存写入失败"));
|
||||
}
|
||||
ChainState loaded = repository.load(legacy.getInstanceId());
|
||||
|
||||
Assert.assertEquals(1, cache.getPutCount());
|
||||
Assert.assertNotSame(legacy, loaded);
|
||||
Assert.assertEquals(legacy.getInstanceId(), loaded.getInstanceId());
|
||||
Assert.assertEquals(legacy.getStatus(), loaded.getStatus());
|
||||
Assert.assertEquals(legacy.getVersion(), loaded.getVersion());
|
||||
Assert.assertEquals(1, stateStore.getCreateCount());
|
||||
Assert.assertEquals(7L, stateStore.getVersionForLastKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工作流状态仓储并注入缓存。
|
||||
* 验证过期版本不能覆盖已经成功提交的新状态。
|
||||
*
|
||||
* @param cache 测试缓存
|
||||
* @return 已完成依赖注入的工作流状态仓储
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void tryUpdateShouldRejectStaleVersion() throws Exception {
|
||||
RecordingCache cache = new RecordingCache(
|
||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||
ChainState created = repository.create("cas-instance");
|
||||
|
||||
ChainState firstUpdate = new ChainState();
|
||||
firstUpdate.setInstanceId(created.getInstanceId());
|
||||
firstUpdate.setVersion(1L);
|
||||
Assert.assertTrue(repository.tryUpdate(
|
||||
firstUpdate, EnumSet.of(ChainStateField.VERSION)));
|
||||
|
||||
ChainState staleUpdate = new ChainState();
|
||||
staleUpdate.setInstanceId(created.getInstanceId());
|
||||
staleUpdate.setVersion(1L);
|
||||
Assert.assertFalse(repository.tryUpdate(
|
||||
staleUpdate, EnumSet.of(ChainStateField.VERSION)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证实例锁成功获取后分配独立的实例 fencing token。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void getLockShouldAllocateInstanceFencingToken() throws Exception {
|
||||
RecordingCache cache = new RecordingCache(
|
||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||
ChainStateRepositoryImpl repository = repository(
|
||||
cache.asCache(), new RecordingVersionedObjectStore());
|
||||
RedisLockExecutor lockExecutor = Mockito.mock(RedisLockExecutor.class);
|
||||
RedisLockExecutor.LockHandle handle =
|
||||
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||
Mockito.when(lockExecutor.tryAcquireFenced(
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.any(Duration.class),
|
||||
ArgumentMatchers.any(Duration.class),
|
||||
ArgumentMatchers.any(Duration.class)
|
||||
)).thenReturn(handle);
|
||||
Mockito.when(handle.getFencingToken()).thenReturn(17L);
|
||||
setField(repository, "redisLockExecutor", lockExecutor);
|
||||
|
||||
ChainLock lock = repository.getLock("fenced-instance", 10L, TimeUnit.SECONDS);
|
||||
try {
|
||||
Assert.assertTrue(lock.isAcquired());
|
||||
Assert.assertEquals(17L, lock.getFencingToken());
|
||||
} finally {
|
||||
lock.close();
|
||||
}
|
||||
|
||||
Mockito.verify(lockExecutor).tryAcquireFenced(
|
||||
ArgumentMatchers.eq("chainLock:{fenced-instance}"),
|
||||
ArgumentMatchers.eq("workflowState:{fenced-instance}:fence"),
|
||||
ArgumentMatchers.any(Duration.class),
|
||||
ArgumentMatchers.any(Duration.class),
|
||||
ArgumentMatchers.eq(Duration.ofDays(4)));
|
||||
Mockito.verify(handle).release();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证状态 CAS 同时校验实例锁和 trigger claim 守卫。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void tryUpdateShouldGuardLockAndSpecificTriggerClaim() throws Exception {
|
||||
RecordingCache cache = new RecordingCache(
|
||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||
VersionedObjectStore stateStore = Mockito.mock(VersionedObjectStore.class);
|
||||
Mockito.when(stateStore.compareAndSetFieldsAndRefresh(
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyLong(),
|
||||
ArgumentMatchers.anyMap(),
|
||||
ArgumentMatchers.anyLong(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyLong(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyLong(),
|
||||
ArgumentMatchers.any(Duration.class),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.any(Duration.class)
|
||||
)).thenReturn(true);
|
||||
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||
ChainState update = new ChainState();
|
||||
update.setInstanceId("claim-guard-instance");
|
||||
update.setVersion(1L);
|
||||
|
||||
Assert.assertTrue(repository.tryUpdate(
|
||||
update,
|
||||
EnumSet.of(ChainStateField.VERSION),
|
||||
17L,
|
||||
"trigger-1",
|
||||
42L));
|
||||
|
||||
Mockito.verify(stateStore).compareAndSetFieldsAndRefresh(
|
||||
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:chain"),
|
||||
ArgumentMatchers.eq(0L),
|
||||
ArgumentMatchers.anyMap(),
|
||||
ArgumentMatchers.eq(1L),
|
||||
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:fence"),
|
||||
ArgumentMatchers.eq(17L),
|
||||
ArgumentMatchers.eq(
|
||||
"workflowState:{claim-guard-instance}:claim:trigger-1"),
|
||||
ArgumentMatchers.eq(42L),
|
||||
ArgumentMatchers.eq(Duration.ofDays(3)),
|
||||
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:format"),
|
||||
ArgumentMatchers.eq(Duration.ofDays(4)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工作流状态仓储并注入测试依赖。
|
||||
*
|
||||
* @param cache 旧 JetCache 测试代理
|
||||
* @param stateStore 版本对象存储
|
||||
* @return 已完成依赖注入的仓储
|
||||
* @throws Exception 反射注入失败时抛出
|
||||
*/
|
||||
private ChainStateRepositoryImpl repository(Cache<String, Object> cache) throws Exception {
|
||||
private ChainStateRepositoryImpl repository(Cache<String, Object> cache,
|
||||
VersionedObjectStore stateStore) throws Exception {
|
||||
ChainStateRepositoryImpl repository = new ChainStateRepositoryImpl();
|
||||
Field field = BaseRepository.class.getDeclaredField("cache");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, cache);
|
||||
Field cacheField = BaseRepository.class.getDeclaredField("cache");
|
||||
cacheField.setAccessible(true);
|
||||
cacheField.set(repository, cache);
|
||||
Field storeField = ChainStateRepositoryImpl.class.getDeclaredField("versionedObjectStore");
|
||||
storeField.setAccessible(true);
|
||||
storeField.set(repository, stateStore);
|
||||
return repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅实现当前仓储测试所需操作的 JetCache 调用记录器。
|
||||
* 反射注入测试依赖。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param name 字段名
|
||||
* @param value 字段值
|
||||
* @throws Exception 字段访问失败时抛出
|
||||
*/
|
||||
private void setField(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅实现当前仓储测试所需读取操作的 JetCache 代理。
|
||||
*/
|
||||
private static final class RecordingCache implements InvocationHandler {
|
||||
|
||||
private final CacheGetResult<Object> getResult;
|
||||
private final CacheResult putResult;
|
||||
private int putCount;
|
||||
private Object lastPutValue;
|
||||
|
||||
/**
|
||||
* 创建缓存调用记录器。
|
||||
* 创建 JetCache 调用记录器。
|
||||
*
|
||||
* @param getResult 读取操作结果
|
||||
* @param putResult 写入操作结果
|
||||
*/
|
||||
private RecordingCache(CacheGetResult<Object> getResult, CacheResult putResult) {
|
||||
private RecordingCache(CacheGetResult<Object> getResult) {
|
||||
this.getResult = getResult;
|
||||
this.putResult = putResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建实现 JetCache 接口的 JDK 动态代理。
|
||||
* 创建实现 JetCache 接口的动态代理。
|
||||
*
|
||||
* @return JetCache 测试代理
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Cache<String, Object> asCache() {
|
||||
return (Cache<String, Object>) Proxy.newProxyInstance(
|
||||
Cache.class.getClassLoader(),
|
||||
new Class<?>[]{Cache.class},
|
||||
this
|
||||
Cache.class.getClassLoader(),
|
||||
new Class<?>[]{Cache.class},
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理仓储发起的缓存读写操作。
|
||||
* 处理仓储发起的缓存调用。
|
||||
*
|
||||
* @param proxy 代理对象
|
||||
* @param method 被调用方法
|
||||
* @param args 调用参数
|
||||
* @return 预设的缓存操作结果
|
||||
* @return 预设结果
|
||||
*/
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) {
|
||||
if ("GET".equals(method.getName())) {
|
||||
return getResult;
|
||||
}
|
||||
if ("PUT".equals(method.getName()) && args != null && args.length == 4) {
|
||||
putCount++;
|
||||
lastPutValue = args[1];
|
||||
Assert.assertEquals(3L, args[2]);
|
||||
Assert.assertEquals(TimeUnit.DAYS, args[3]);
|
||||
return putResult;
|
||||
if ("REMOVE".equals(method.getName())) {
|
||||
return CacheResult.SUCCESS_WITHOUT_MSG;
|
||||
}
|
||||
throw new UnsupportedOperationException("unsupported cache method: " + method.getName());
|
||||
throw new UnsupportedOperationException(
|
||||
"unsupported cache method: " + method.getName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以进程内 Map 模拟原子版本对象存储。
|
||||
*/
|
||||
private static final class RecordingVersionedObjectStore implements VersionedObjectStore {
|
||||
|
||||
private final Map<String, Serializable> values = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, Object>> fieldValues = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> versions = new ConcurrentHashMap<>();
|
||||
private int createCount;
|
||||
private Map<String, Object> lastCreatedFields;
|
||||
private String lastKey;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public <T> T load(String key, Class<T> type) {
|
||||
Serializable value = values.get(key);
|
||||
return value == null ? null : type.cast(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取写入调用次数。
|
||||
*
|
||||
* @return 写入调用次数
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
private int getPutCount() {
|
||||
return putCount;
|
||||
@Override
|
||||
public VersionedFields loadFields(String key) {
|
||||
Map<String, Object> fields = fieldValues.get(key);
|
||||
Long version = versions.get(key);
|
||||
return fields == null || version == null
|
||||
? null
|
||||
: new VersionedFields(version, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一次写入的缓存值。
|
||||
*
|
||||
* @return 最后一次写入的缓存值
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
private Object getLastPutValue() {
|
||||
return lastPutValue;
|
||||
@Override
|
||||
public synchronized boolean createFieldsIfAbsent(
|
||||
String key,
|
||||
Map<String, ? extends Serializable> fields,
|
||||
long version,
|
||||
Duration ttl) {
|
||||
if (fieldValues.containsKey(key) || values.containsKey(key)) {
|
||||
return false;
|
||||
}
|
||||
fieldValues.put(key, new LinkedHashMap<>(fields));
|
||||
versions.put(key, version);
|
||||
if (!key.endsWith(":format")) {
|
||||
createCount++;
|
||||
lastCreatedFields = new LinkedHashMap<>(fields);
|
||||
lastKey = key;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public synchronized boolean compareAndSetFields(
|
||||
String key,
|
||||
long expectedVersion,
|
||||
Map<String, ? extends Serializable> fields,
|
||||
long newVersion,
|
||||
Duration ttl) {
|
||||
Long currentVersion = versions.get(key);
|
||||
if (currentVersion == null || currentVersion != expectedVersion) {
|
||||
return false;
|
||||
}
|
||||
fieldValues.computeIfAbsent(key, ignored -> new LinkedHashMap<>()).putAll(fields);
|
||||
versions.put(key, newVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public synchronized boolean rewriteAsFields(
|
||||
String key,
|
||||
long expectedVersion,
|
||||
Map<String, ? extends Serializable> fields,
|
||||
Duration ttl) {
|
||||
Long currentVersion = versions.get(key);
|
||||
if (currentVersion == null || currentVersion != expectedVersion) {
|
||||
return false;
|
||||
}
|
||||
values.remove(key);
|
||||
fieldValues.put(key, new LinkedHashMap<>(fields));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public synchronized boolean createIfAbsent(String key,
|
||||
Serializable value,
|
||||
long version,
|
||||
Duration ttl) {
|
||||
if (values.containsKey(key)) {
|
||||
return false;
|
||||
}
|
||||
values.put(key, value);
|
||||
versions.put(key, version);
|
||||
createCount++;
|
||||
lastKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean createIfAbsent(String key,
|
||||
Serializable value,
|
||||
long version,
|
||||
String guardKey,
|
||||
long guardVersion,
|
||||
Duration ttl) {
|
||||
Long currentGuard = versions.get(guardKey);
|
||||
return currentGuard != null
|
||||
&& currentGuard == guardVersion
|
||||
&& createIfAbsent(key, value, version, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public synchronized boolean compareAndSet(String key,
|
||||
long expectedVersion,
|
||||
Serializable value,
|
||||
long newVersion,
|
||||
Duration ttl) {
|
||||
Long currentVersion = versions.get(key);
|
||||
if (currentVersion == null || currentVersion != expectedVersion) {
|
||||
return false;
|
||||
}
|
||||
values.put(key, value);
|
||||
versions.put(key, newVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean compareAndSet(String key,
|
||||
long expectedVersion,
|
||||
Serializable value,
|
||||
long newVersion,
|
||||
String guardKey,
|
||||
long guardVersion,
|
||||
Duration ttl) {
|
||||
Long currentGuard = versions.get(guardKey);
|
||||
return currentGuard != null
|
||||
&& currentGuard == guardVersion
|
||||
&& compareAndSet(key, expectedVersion, value, newVersion, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建次数。
|
||||
*
|
||||
* @return 创建次数
|
||||
*/
|
||||
private int getCreateCount() {
|
||||
return createCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后创建的对象。
|
||||
*
|
||||
* @return 最后创建的对象
|
||||
*/
|
||||
private Map<String, Object> getLastCreatedFields() {
|
||||
return lastCreatedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后写入键的版本。
|
||||
*
|
||||
* @return 最后写入版本
|
||||
*/
|
||||
private long getVersionForLastKey() {
|
||||
return versions.get(lastKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.repository.LoopResultReference;
|
||||
import com.easyagents.flow.core.chain.repository.LoopInputReference;
|
||||
import com.easyagents.flow.core.chain.runtime.TriggerClaimLostException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.common.cache.VersionedObjectStore;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 循环结果分块仓储测试。
|
||||
*/
|
||||
public class LoopResultRepositoryImplTest {
|
||||
|
||||
/**
|
||||
* 验证跨多个分块的结果顺序、完整性及幂等重放。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveOrderingAcrossChunkBoundaries() {
|
||||
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
|
||||
String resultId = "loop-result";
|
||||
int iterations = LoopResultRepositoryImpl.CHUNK_SIZE * 2 + 1;
|
||||
|
||||
for (int index = 0; index < iterations; index++) {
|
||||
repository.append(resultId, index, Map.of(
|
||||
"index", index,
|
||||
"value", "value-" + index));
|
||||
}
|
||||
repository.append(resultId, iterations - 1, Map.of(
|
||||
"index", iterations - 1,
|
||||
"value", "value-" + (iterations - 1)));
|
||||
|
||||
Map<String, Object> result =
|
||||
repository.load(resultId, iterations, List.of("index", "value"));
|
||||
Assert.assertEquals(iterations, ((List<?>) result.get("index")).size());
|
||||
Assert.assertEquals(0, ((List<?>) result.get("index")).get(0));
|
||||
Assert.assertEquals(iterations - 1, ((List<?>) result.get("index")).get(iterations - 1));
|
||||
Assert.assertEquals("value-128", ((List<?>) result.get("value")).get(128));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一轮次写入不同结果时拒绝覆盖。
|
||||
*/
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void shouldRejectConflictingReplay() {
|
||||
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
|
||||
repository.append("loop-result", 0, Map.of("value", "first"));
|
||||
repository.append("loop-result", 0, Map.of("value", "changed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证热状态只保存轻量引用,业务读取边界仍还原为原有列表结构。
|
||||
*/
|
||||
@Test
|
||||
public void shouldResolveLightweightReferenceAtReadBoundary() {
|
||||
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
|
||||
String resultId = "instance:loop-result";
|
||||
repository.append(resultId, 0, Map.of("value", "first"));
|
||||
repository.append(resultId, 1, Map.of("value", "second"));
|
||||
|
||||
Map<String, Object> references = repository.references(
|
||||
resultId, 2, List.of("value"));
|
||||
Assert.assertTrue(references.get("value") instanceof LoopResultReference);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> resolved =
|
||||
(Map<String, Object>) repository.resolveReferences(references);
|
||||
Assert.assertEquals(List.of("first", "second"), resolved.get("value"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证没有声明输出的长循环跨分块时仍会续期输入生命周期。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldRefreshInputChunksWhenLoopHasNoOutputs() throws Exception {
|
||||
LoopResultRepositoryImpl repository = new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store = mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class.getDeclaredField(
|
||||
"versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
when(store.load(any(String.class), eq(Integer.class))).thenReturn(256);
|
||||
when(store.createIfAbsent(
|
||||
anyString(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class))).thenReturn(true);
|
||||
|
||||
repository.append(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:loop",
|
||||
LoopResultRepositoryImpl.CHUNK_SIZE,
|
||||
Map.of());
|
||||
|
||||
verify(store).refreshExpirations(anyList(), any(Duration.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一输入分块内的多轮读取只访问一次底层对象存储。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldLoadEachInputChunkOnlyOnce() throws Exception {
|
||||
LoopResultRepositoryImpl repository = new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store = mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class.getDeclaredField(
|
||||
"versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
List<Object> values = java.util.stream.IntStream.range(0, 128)
|
||||
.boxed()
|
||||
.map(value -> (Object) value)
|
||||
.toList();
|
||||
when(store.load(anyString(), eq(List.class))).thenReturn(values);
|
||||
|
||||
Assert.assertEquals(0, repository.loadInputItem("instance:loop", 0));
|
||||
Assert.assertEquals(127, repository.loadInputItem("instance:loop", 127));
|
||||
|
||||
verify(store, times(1)).load(anyString(), eq(List.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证调用方修改已读取的可变输入时不会污染活动分块缓存。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldIsolateMutableInputValuesFromActiveCache()
|
||||
throws Exception {
|
||||
LoopResultRepositoryImpl repository =
|
||||
new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store =
|
||||
mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
Map<String, Object> persisted =
|
||||
new LinkedHashMap<>();
|
||||
persisted.put("name", "original");
|
||||
when(store.load(anyString(), eq(List.class)))
|
||||
.thenReturn(List.of(persisted));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> first =
|
||||
(Map<String, Object>)
|
||||
repository.loadInputItem(
|
||||
"instance:mutable-input",
|
||||
0);
|
||||
first.put("name", "changed");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> second =
|
||||
(Map<String, Object>)
|
||||
repository.loadInputItem(
|
||||
"instance:mutable-input",
|
||||
0);
|
||||
|
||||
Assert.assertEquals(
|
||||
"original", second.get("name"));
|
||||
verify(store, times(1)).load(
|
||||
anyString(), eq(List.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证完整输入还原按分块批量读取,并保持不同调用方的可变值隔离。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldBulkLoadMutableInputChunks()
|
||||
throws Exception {
|
||||
LoopResultRepositoryImpl repository =
|
||||
new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store =
|
||||
mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
int itemCount =
|
||||
LoopResultRepositoryImpl.CHUNK_SIZE
|
||||
* 2 + 1;
|
||||
when(store.loadAll(
|
||||
anyList(), eq(List.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
List<List<Object>> chunks =
|
||||
new java.util.ArrayList<>();
|
||||
for (int chunkIndex = 0;
|
||||
chunkIndex < 3;
|
||||
chunkIndex++) {
|
||||
int chunkSize = chunkIndex < 2
|
||||
? LoopResultRepositoryImpl.CHUNK_SIZE
|
||||
: 1;
|
||||
List<Object> chunk =
|
||||
new java.util.ArrayList<>();
|
||||
for (int offset = 0;
|
||||
offset < chunkSize;
|
||||
offset++) {
|
||||
Map<String, Object> value =
|
||||
new LinkedHashMap<>();
|
||||
value.put(
|
||||
"index",
|
||||
chunkIndex
|
||||
* LoopResultRepositoryImpl.CHUNK_SIZE
|
||||
+ offset);
|
||||
chunk.add(value);
|
||||
}
|
||||
chunks.add(chunk);
|
||||
}
|
||||
return chunks;
|
||||
});
|
||||
LoopInputReference reference =
|
||||
new LoopInputReference(
|
||||
"instance:bulk-input",
|
||||
itemCount);
|
||||
|
||||
List<Object> first =
|
||||
repository.loadInput(reference);
|
||||
((Map<String, Object>) first.get(0))
|
||||
.put("index", -1);
|
||||
List<Object> second =
|
||||
repository.loadInput(reference);
|
||||
|
||||
Assert.assertEquals(
|
||||
itemCount, second.size());
|
||||
Assert.assertEquals(
|
||||
0,
|
||||
((Map<String, Object>) second.get(0))
|
||||
.get("index"));
|
||||
verify(store, times(2)).loadAll(
|
||||
anyList(), eq(List.class));
|
||||
verify(store, times(0)).load(
|
||||
anyString(), eq(List.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证连续循环输出命中活动分块缓存时不重复读取 Redis 对象。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldReuseActiveOutputChunkAfterSuccessfulCommit()
|
||||
throws Exception {
|
||||
LoopResultRepositoryImpl repository =
|
||||
new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store =
|
||||
mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
when(store.load(
|
||||
anyString(),
|
||||
eq(LoopResultRepositoryImpl
|
||||
.LoopResultChunk.class)))
|
||||
.thenReturn(null);
|
||||
when(store.createIfAbsent(
|
||||
anyString(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
when(store.compareAndSet(
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
|
||||
repository.append(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:cached-output",
|
||||
0,
|
||||
Map.of("value", "first"));
|
||||
repository.append(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:cached-output",
|
||||
1,
|
||||
Map.of("value", "second"));
|
||||
|
||||
verify(store, times(1)).load(
|
||||
anyString(),
|
||||
eq(LoopResultRepositoryImpl
|
||||
.LoopResultChunk.class));
|
||||
verify(store, times(1)).compareAndSet(
|
||||
anyString(),
|
||||
eq(0L),
|
||||
any(Serializable.class),
|
||||
eq(1L),
|
||||
anyString(),
|
||||
eq(1L),
|
||||
anyString(),
|
||||
eq(1L),
|
||||
any(Duration.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证调用方修改已提交的可变输出时不会污染下一轮活动分块写入。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldIsolateMutableOutputValuesFromActiveCache()
|
||||
throws Exception {
|
||||
LoopResultRepositoryImpl repository =
|
||||
new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store =
|
||||
mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
when(store.load(
|
||||
anyString(),
|
||||
eq(LoopResultRepositoryImpl
|
||||
.LoopResultChunk.class)))
|
||||
.thenReturn(null);
|
||||
when(store.createIfAbsent(
|
||||
anyString(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
when(store.compareAndSet(
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
Map<String, Object> firstValue =
|
||||
new LinkedHashMap<>();
|
||||
firstValue.put("name", "original");
|
||||
|
||||
repository.append(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:mutable-output",
|
||||
0,
|
||||
Map.of("value", firstValue));
|
||||
firstValue.put("name", "changed");
|
||||
repository.append(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:mutable-output",
|
||||
1,
|
||||
Map.of("value", Map.of(
|
||||
"name", "second")));
|
||||
|
||||
org.mockito.ArgumentCaptor<Serializable>
|
||||
chunkCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
Serializable.class);
|
||||
verify(store).compareAndSet(
|
||||
anyString(),
|
||||
eq(0L),
|
||||
chunkCaptor.capture(),
|
||||
eq(1L),
|
||||
anyString(),
|
||||
eq(1L),
|
||||
anyString(),
|
||||
eq(1L),
|
||||
any(Duration.class));
|
||||
LoopResultRepositoryImpl.LoopResultChunk
|
||||
committed =
|
||||
(LoopResultRepositoryImpl.LoopResultChunk)
|
||||
chunkCaptor.getValue();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> committedFirst =
|
||||
(Map<String, Object>)
|
||||
committed.getValues()
|
||||
.get("value")
|
||||
.get(0);
|
||||
Assert.assertEquals(
|
||||
"original",
|
||||
committedFirst.get("name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证跨分块后活动输出缓存只保留当前分块。
|
||||
*
|
||||
* @throws Exception 测试依赖注入或反射读取失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepOnlyCurrentOutputChunkInActiveCache()
|
||||
throws Exception {
|
||||
LoopResultRepositoryImpl repository =
|
||||
new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store =
|
||||
mock(VersionedObjectStore.class);
|
||||
Field storeField = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("versionedObjectStore");
|
||||
storeField.setAccessible(true);
|
||||
storeField.set(repository, store);
|
||||
when(store.createIfAbsent(
|
||||
anyString(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class)))
|
||||
.thenReturn(true);
|
||||
|
||||
repository.append(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:chunk-release",
|
||||
0,
|
||||
Map.of("value", "first"));
|
||||
repository.append(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:chunk-release",
|
||||
LoopResultRepositoryImpl.CHUNK_SIZE,
|
||||
Map.of("value", "next"));
|
||||
|
||||
Field cacheField = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("outputChunkCache");
|
||||
cacheField.setAccessible(true);
|
||||
Object cache = cacheField.get(repository);
|
||||
Field valuesField = cache.getClass()
|
||||
.getDeclaredField("values");
|
||||
valuesField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> cachedValues =
|
||||
(Map<String, Object>)
|
||||
valuesField.get(cache);
|
||||
Assert.assertEquals(
|
||||
1, cachedValues.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证物化中失去 fencing 守卫后立即停止并清理本 owner 已写分块。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldCleanupPartialInputWhenClaimIsLost()
|
||||
throws Exception {
|
||||
LoopResultRepositoryImpl repository =
|
||||
new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store =
|
||||
mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
when(store.createIfAbsent(
|
||||
anyString(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class)))
|
||||
.thenReturn(true, false);
|
||||
List<Integer> input = java.util.stream.IntStream
|
||||
.range(0, LoopResultRepositoryImpl.CHUNK_SIZE + 1)
|
||||
.boxed()
|
||||
.toList();
|
||||
|
||||
try {
|
||||
repository.storeInput(
|
||||
"instance",
|
||||
1L,
|
||||
"claim",
|
||||
1L,
|
||||
"instance:guarded-input",
|
||||
input,
|
||||
10_000L);
|
||||
Assert.fail("lost claim must stop materialization");
|
||||
} catch (TriggerClaimLostException expected) {
|
||||
// 第二个分块守卫失败后立即退出。
|
||||
}
|
||||
|
||||
verify(store).deleteAll(
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
keys -> keys.size() == 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证锁外物化只依赖稳定 claim,合法实例锁代际推进不会中断后续分块。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldMaterializeAllChunksWithStableClaimGuard()
|
||||
throws Exception {
|
||||
LoopResultRepositoryImpl repository =
|
||||
new LoopResultRepositoryImpl();
|
||||
VersionedObjectStore store =
|
||||
mock(VersionedObjectStore.class);
|
||||
Field field = LoopResultRepositoryImpl.class
|
||||
.getDeclaredField("versionedObjectStore");
|
||||
field.setAccessible(true);
|
||||
field.set(repository, store);
|
||||
when(store.createIfAbsent(
|
||||
anyString(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
anyLong(),
|
||||
any(Duration.class))).thenReturn(true);
|
||||
List<Integer> input = java.util.stream.IntStream
|
||||
.range(0, LoopResultRepositoryImpl.CHUNK_SIZE + 1)
|
||||
.boxed()
|
||||
.toList();
|
||||
|
||||
int stored = repository.storeProducedInput(
|
||||
"instance",
|
||||
0L,
|
||||
"claim",
|
||||
7L,
|
||||
"instance:stable-input",
|
||||
sink -> input.forEach(sink),
|
||||
10_000L);
|
||||
|
||||
Assert.assertEquals(input.size(), stored);
|
||||
verify(store, times(3)).createIfAbsent(
|
||||
anyString(),
|
||||
any(Serializable.class),
|
||||
anyLong(),
|
||||
anyString(),
|
||||
eq(7L),
|
||||
any(Duration.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用内存 Map 隔离 JetCache 的测试仓储。
|
||||
*/
|
||||
private static final class InMemoryLoopResultRepository extends LoopResultRepositoryImpl {
|
||||
|
||||
private final Map<String, Object> values = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 将分块写入测试内存。
|
||||
*
|
||||
* @param key 缓存键
|
||||
* @param value 缓存值
|
||||
*/
|
||||
@Override
|
||||
protected void putCache(String key, Object value) {
|
||||
values.put(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从测试内存读取分块。
|
||||
*
|
||||
* @param key 缓存键
|
||||
* @param clazz 期望类型
|
||||
* @param <T> 缓存值类型
|
||||
* @return 命中的分块
|
||||
*/
|
||||
@Override
|
||||
protected <T> T getCache(String key, Class<T> clazz) {
|
||||
return clazz.cast(values.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.runtime.Trigger;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.ZSetOperations;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@link RedisTriggerStore} 分布式认领语义回归测试。
|
||||
*/
|
||||
public class RedisTriggerStoreTest {
|
||||
|
||||
/**
|
||||
* 验证同一到期窗口超过 200 条任务时仍可一次填充本地调度容量。
|
||||
*
|
||||
* @throws Exception 测试触发器序列化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void findDueShouldLoadMoreThanLegacyBatchLimit()
|
||||
throws Exception {
|
||||
StringRedisTemplate redisTemplate =
|
||||
Mockito.mock(StringRedisTemplate.class);
|
||||
ZSetOperations<String, String> zSetOperations =
|
||||
Mockito.mock(ZSetOperations.class);
|
||||
ValueOperations<String, String> valueOperations =
|
||||
Mockito.mock(ValueOperations.class);
|
||||
Mockito.when(redisTemplate.opsForZSet())
|
||||
.thenReturn(zSetOperations);
|
||||
Mockito.when(redisTemplate.opsForValue())
|
||||
.thenReturn(valueOperations);
|
||||
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
List<String> payloads = new ArrayList<>();
|
||||
for (int index = 0; index < 512; index++) {
|
||||
String id = "due-" + index;
|
||||
Trigger trigger = new Trigger();
|
||||
trigger.setId(id);
|
||||
trigger.setStateInstanceId(
|
||||
"instance-" + index);
|
||||
trigger.setTriggerAt(1000L);
|
||||
ids.add(id);
|
||||
payloads.add(
|
||||
objectMapper.writeValueAsString(
|
||||
trigger));
|
||||
}
|
||||
Mockito.when(zSetOperations.rangeByScore(
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyDouble(),
|
||||
ArgumentMatchers.anyDouble(),
|
||||
ArgumentMatchers.eq(0L),
|
||||
ArgumentMatchers.eq(1024L)))
|
||||
.thenReturn(ids);
|
||||
Mockito.when(valueOperations.multiGet(
|
||||
ArgumentMatchers.anyList()))
|
||||
.thenReturn(payloads);
|
||||
RedisTriggerStore store =
|
||||
new RedisTriggerStore(
|
||||
redisTemplate,
|
||||
objectMapper);
|
||||
|
||||
List<Trigger> due =
|
||||
store.findDue(1000L);
|
||||
|
||||
Assert.assertEquals(512, due.size());
|
||||
Mockito.verify(zSetOperations)
|
||||
.rangeByScore(
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.eq(0.0),
|
||||
ArgumentMatchers.eq(1000.0),
|
||||
ArgumentMatchers.eq(0L),
|
||||
ArgumentMatchers.eq(1024L));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证稳定触发器通过单条 Redis 脚本完成存在性判断和创建。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public void saveIfAbsentShouldUseAtomicRedisScript() {
|
||||
StringRedisTemplate redisTemplate =
|
||||
Mockito.mock(StringRedisTemplate.class);
|
||||
Mockito.doReturn(1L).when(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
RedisTriggerStore store =
|
||||
new RedisTriggerStore(
|
||||
redisTemplate,
|
||||
new ObjectMapper());
|
||||
Trigger trigger = new Trigger();
|
||||
trigger.setId("stable-trigger");
|
||||
trigger.setTriggerAt(
|
||||
System.currentTimeMillis());
|
||||
|
||||
Assert.assertTrue(
|
||||
store.saveIfAbsent(trigger));
|
||||
|
||||
ArgumentCaptor<RedisScript<Long>>
|
||||
scriptCaptor =
|
||||
ArgumentCaptor.forClass(
|
||||
(Class) RedisScript.class);
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
scriptCaptor.capture(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
String script =
|
||||
scriptCaptor.getValue()
|
||||
.getScriptAsString();
|
||||
Assert.assertTrue(script.contains(
|
||||
"exists', KEYS[1]"));
|
||||
Assert.assertTrue(script.contains(
|
||||
"psetex', KEYS[1]"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证认领触发器分配一次独立代际,并创建与该 trigger claim 绑定的执行守卫。
|
||||
*
|
||||
* <p>认领代际与实例锁 fencing token 使用不同计数器,claim 不推进实例锁 fence。</p>
|
||||
*
|
||||
* @throws Exception JSON 构造失败时抛出
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public void claimShouldCreateTriggerScopedExecutionGuard() throws Exception {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
Trigger stored = new Trigger();
|
||||
stored.setId("trigger-1");
|
||||
stored.setStateInstanceId("instance-1");
|
||||
stored.setTriggerAt(System.currentTimeMillis());
|
||||
stored.setFencingToken(7L);
|
||||
String payload = objectMapper.writeValueAsString(stored);
|
||||
Mockito.doReturn("8\n" + payload).when(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<String>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
RedisTriggerStore store =
|
||||
new RedisTriggerStore(redisTemplate, objectMapper);
|
||||
|
||||
Trigger claimed = store.claim(stored, 60_000L);
|
||||
|
||||
Assert.assertNotNull(claimed);
|
||||
Assert.assertEquals(8L, claimed.getFencingToken());
|
||||
ArgumentCaptor<RedisScript<String>> scriptCaptor =
|
||||
ArgumentCaptor.forClass((Class) RedisScript.class);
|
||||
ArgumentCaptor<List<String>> keysCaptor =
|
||||
ArgumentCaptor.forClass((Class) List.class);
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
scriptCaptor.capture(),
|
||||
keysCaptor.capture(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
Assert.assertTrue(
|
||||
scriptCaptor.getValue().getScriptAsString().contains(
|
||||
"hset', KEYS[4], 'version'"));
|
||||
Assert.assertEquals(
|
||||
"workflowState:{instance-1}:claim:trigger-1",
|
||||
keysCaptor.getValue().get(3));
|
||||
Assert.assertEquals(
|
||||
"workflowState:{instance-1}:claim-seq",
|
||||
keysCaptor.getValue().get(4));
|
||||
Assert.assertTrue(
|
||||
scriptCaptor.getValue().getScriptAsString().contains(
|
||||
"hincrby', KEYS[5], 'version'"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
|
||||
import tech.easyflow.ai.easyagentsflow.event.WorkflowDefinitionChangedEvent;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* {@link WorkflowDefinitionCache} 命中、失效和编译去重回归测试。
|
||||
*/
|
||||
public class WorkflowDefinitionCacheTest {
|
||||
|
||||
/**
|
||||
* 验证同一版本重复执行只编译一次。
|
||||
*/
|
||||
@Test
|
||||
public void shouldCompileOnlyOnceForRepeatedReads() {
|
||||
InMemoryVersionStore versionStore = new InMemoryVersionStore();
|
||||
WorkflowDefinitionCache cache = cache(versionStore);
|
||||
AtomicInteger loads = new AtomicInteger();
|
||||
|
||||
ChainDefinition first = cache.get("1", () -> definition("1", loads));
|
||||
ChainDefinition second = cache.get("1", () -> definition("1", loads));
|
||||
|
||||
Assert.assertSame(first, second);
|
||||
Assert.assertEquals(1, loads.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证工作流变更后草稿态和发布态缓存同时失效。
|
||||
*/
|
||||
@Test
|
||||
public void shouldInvalidateDraftAndPublishedDefinitionsTogether() {
|
||||
InMemoryVersionStore versionStore = new InMemoryVersionStore();
|
||||
WorkflowDefinitionCache cache = cache(versionStore);
|
||||
AtomicInteger loads = new AtomicInteger();
|
||||
String publishedId = PublishedWorkflowDefinitionIds.published("2");
|
||||
|
||||
ChainDefinition draftBefore = cache.get("2", () -> definition("2", loads));
|
||||
ChainDefinition publishedBefore = cache.get(publishedId, () -> definition(publishedId, loads));
|
||||
cache.onDefinitionChanged(new WorkflowDefinitionChangedEvent("2"));
|
||||
ChainDefinition draftAfter = cache.get("2", () -> definition("2", loads));
|
||||
ChainDefinition publishedAfter = cache.get(publishedId, () -> definition(publishedId, loads));
|
||||
|
||||
Assert.assertNotSame(draftBefore, draftAfter);
|
||||
Assert.assertNotSame(publishedBefore, publishedAfter);
|
||||
Assert.assertEquals(4, loads.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试缓存。
|
||||
*
|
||||
* @param versionStore 版本令牌仓储
|
||||
* @return 定义缓存
|
||||
*/
|
||||
private WorkflowDefinitionCache cache(WorkflowDefinitionVersionStore versionStore) {
|
||||
WorkflowRuntimeProperties properties = new WorkflowRuntimeProperties();
|
||||
properties.setDefinitionCacheMaxEntries(4);
|
||||
return new WorkflowDefinitionCache(versionStore, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试定义并记录编译次数。
|
||||
*
|
||||
* @param id 定义 ID
|
||||
* @param loads 编译计数
|
||||
* @return 工作流定义
|
||||
*/
|
||||
private ChainDefinition definition(String id, AtomicInteger loads) {
|
||||
loads.incrementAndGet();
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
definition.setId(id);
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程内版本令牌测试仓储。
|
||||
*/
|
||||
private static final class InMemoryVersionStore implements WorkflowDefinitionVersionStore {
|
||||
|
||||
private final Map<String, String> tokens = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public String currentToken(String definitionId) {
|
||||
return tokens.computeIfAbsent(definitionId, ignored -> UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void invalidateWorkflow(String workflowId) {
|
||||
tokens.put(workflowId, UUID.randomUUID().toString());
|
||||
tokens.put(PublishedWorkflowDefinitionIds.published(workflowId), UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package tech.easyflow.ai.easyagentsflow.repository;
|
||||
|
||||
import com.easyagents.flow.core.chain.NodeState;
|
||||
import com.easyagents.flow.core.chain.repository.NodeStateField;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
|
||||
import tech.easyflow.common.cache.VersionedFields;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 节点字段化状态编码回归测试。
|
||||
*/
|
||||
public class WorkflowStateFieldsNodeTest {
|
||||
|
||||
/**
|
||||
* 验证节点生命周期业务尝试键可跨 Redis 字段快照恢复。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveExecutionAttemptKey() {
|
||||
NodeState state = new NodeState();
|
||||
state.setNodeId("loop");
|
||||
state.setChainInstanceId("instance");
|
||||
state.setExecutionAttemptKey(
|
||||
"instance:loop:trigger");
|
||||
state.setVersion(7L);
|
||||
|
||||
Map<String, Object> encoded =
|
||||
new LinkedHashMap<>(
|
||||
WorkflowStateFields
|
||||
.allNodeFields(state));
|
||||
NodeState decoded =
|
||||
WorkflowStateFields.decodeNode(
|
||||
new VersionedFields(
|
||||
7L,
|
||||
encoded));
|
||||
|
||||
Assert.assertEquals(
|
||||
"instance:loop:trigger",
|
||||
decoded
|
||||
.getExecutionAttemptKey());
|
||||
Assert.assertEquals(
|
||||
7L, decoded.getVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证升级前在途节点沿用 memory.executeId,避免结束审计关联到新键。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRestoreLegacyExecutionKey() {
|
||||
NodeState legacyState = new NodeState();
|
||||
legacyState.setNodeId("loop");
|
||||
legacyState.setChainInstanceId(
|
||||
"instance");
|
||||
legacyState.getMemory().put(
|
||||
"executeId",
|
||||
"legacy-step-key");
|
||||
|
||||
Map<String, Object> encoded =
|
||||
new LinkedHashMap<>(
|
||||
WorkflowStateFields
|
||||
.allNodeFields(
|
||||
legacyState));
|
||||
encoded.remove(
|
||||
NodeStateField
|
||||
.EXECUTION_ATTEMPT_KEY
|
||||
.name());
|
||||
|
||||
NodeState decoded =
|
||||
WorkflowStateFields.decodeNode(
|
||||
new VersionedFields(
|
||||
3L,
|
||||
encoded));
|
||||
|
||||
Assert.assertEquals(
|
||||
"legacy-step-key",
|
||||
WorkflowExecutionStepKey.resolve(
|
||||
decoded
|
||||
.getExecutionAttemptKey()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证旧对象快照同样补齐最终执行键。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNormalizeLegacyObjectState() {
|
||||
NodeState legacyState = new NodeState();
|
||||
legacyState.getMemory().put(
|
||||
"executeId",
|
||||
"legacy-object-step");
|
||||
|
||||
WorkflowStateFields.normalizeNode(
|
||||
legacyState);
|
||||
|
||||
Assert.assertEquals(
|
||||
"legacy-object-step",
|
||||
WorkflowExecutionStepKey.resolve(
|
||||
legacyState
|
||||
.getExecutionAttemptKey()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.NodeState;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 工作流设计器状态轮询服务测试。
|
||||
*/
|
||||
public class TinyFlowServiceTest {
|
||||
|
||||
private static final String EXECUTE_ID = "execution-1";
|
||||
private static final String NODE_ID = "node-1";
|
||||
|
||||
/**
|
||||
* 验证尚未启动的节点返回 READY,且一次轮询只读取一次工作流状态。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnReadyForMissingNodeStateWithoutRepeatedChainReads()
|
||||
throws Exception {
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
ChainStateRepository chainStateRepository =
|
||||
mock(ChainStateRepository.class);
|
||||
NodeStateRepository nodeStateRepository =
|
||||
mock(NodeStateRepository.class);
|
||||
ChainState chainState = new ChainState();
|
||||
chainState.setStatus(ChainStatus.RUNNING);
|
||||
when(chainExecutor.getChainStateRepository())
|
||||
.thenReturn(chainStateRepository);
|
||||
when(chainExecutor.getNodeStateRepository())
|
||||
.thenReturn(nodeStateRepository);
|
||||
when(chainStateRepository.load(EXECUTE_ID))
|
||||
.thenReturn(chainState);
|
||||
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
|
||||
.thenReturn(null);
|
||||
TinyFlowService service = service(chainExecutor);
|
||||
NodeInfo node = node(NodeStatus.SUCCEEDED);
|
||||
|
||||
ChainInfo result = service.getChainStatus(
|
||||
EXECUTE_ID, List.of(node));
|
||||
|
||||
Assert.assertEquals(
|
||||
Integer.valueOf(ChainStatus.RUNNING.getValue()),
|
||||
result.getStatus());
|
||||
Assert.assertEquals(
|
||||
Integer.valueOf(NodeStatus.READY.getValue()),
|
||||
result.getNodes().get(NODE_ID).getStatus());
|
||||
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
|
||||
verify(nodeStateRepository, times(1))
|
||||
.load(EXECUTE_ID, NODE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证已存在节点仍返回仓储中的真实执行状态。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnPersistedNodeStatus()
|
||||
throws Exception {
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
ChainStateRepository chainStateRepository =
|
||||
mock(ChainStateRepository.class);
|
||||
NodeStateRepository nodeStateRepository =
|
||||
mock(NodeStateRepository.class);
|
||||
ChainState chainState = new ChainState();
|
||||
chainState.setStatus(ChainStatus.RUNNING);
|
||||
NodeState nodeState = new NodeState();
|
||||
nodeState.setStatus(NodeStatus.RUNNING);
|
||||
when(chainExecutor.getChainStateRepository())
|
||||
.thenReturn(chainStateRepository);
|
||||
when(chainExecutor.getNodeStateRepository())
|
||||
.thenReturn(nodeStateRepository);
|
||||
when(chainStateRepository.load(EXECUTE_ID))
|
||||
.thenReturn(chainState);
|
||||
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
|
||||
.thenReturn(nodeState);
|
||||
TinyFlowService service = service(chainExecutor);
|
||||
|
||||
ChainInfo result = service.getChainStatus(
|
||||
EXECUTE_ID, List.of(node(NodeStatus.READY)));
|
||||
|
||||
Assert.assertEquals(
|
||||
Integer.valueOf(NodeStatus.RUNNING.getValue()),
|
||||
result.getNodes().get(NODE_ID).getStatus());
|
||||
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
|
||||
verify(nodeStateRepository, times(1))
|
||||
.load(EXECUTE_ID, NODE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带指定初始状态的设计器节点。
|
||||
*
|
||||
* @param status 初始节点状态
|
||||
* @return 设计器节点
|
||||
*/
|
||||
private NodeInfo node(NodeStatus status) {
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId(NODE_ID);
|
||||
node.setStatus(status.getValue());
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并注入执行器的轮询服务。
|
||||
*
|
||||
* @param chainExecutor 工作流执行器
|
||||
* @return 已完成依赖注入的服务
|
||||
* @throws Exception 反射访问失败时抛出
|
||||
*/
|
||||
private TinyFlowService service(ChainExecutor chainExecutor)
|
||||
throws Exception {
|
||||
TinyFlowService service = new TinyFlowService();
|
||||
Field field = TinyFlowService.class.getDeclaredField(
|
||||
"chainExecutor");
|
||||
field.setAccessible(true);
|
||||
field.set(service, chainExecutor);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,89 @@ import java.util.Map;
|
||||
|
||||
public class WorkflowCheckServiceTest {
|
||||
|
||||
/**
|
||||
* 验证普通节点循环次数必须处于 1~300。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockConfiguredLoopCountAboveLimit() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject codeData = data("循环处理");
|
||||
codeData.put("loopEnable", true);
|
||||
codeData.put("maxLoopCount", 301);
|
||||
String content = workflowJson(
|
||||
array(node("code-1", "codeNode", null, codeData)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "LOOP_COUNT_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证显式循环节点的固定次数不能为零。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockFixedExplicitLoopCountZero() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject loopData = data("循环");
|
||||
JSONObject loopVar = new JSONObject();
|
||||
loopVar.put("name", "loopVar");
|
||||
loopVar.put("refType", "fixed");
|
||||
loopVar.put("value", "0");
|
||||
loopData.put("loopVars", array(loopVar));
|
||||
String content = workflowJson(
|
||||
array(node("loop-1", "loopNode", null, loopData)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "EXPLICIT_LOOP_COUNT_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证嵌套节点只能挂在显式循环节点下。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockNonLoopParent() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("code-parent", "codeNode", null, data("父节点")),
|
||||
node("code-child", "codeNode", "code-parent", data("子节点"))
|
||||
),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "NODE_PARENT_NOT_LOOP");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证显式循环嵌套层级不能形成 parentId 环。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockLoopParentCycle() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("loop-a", "loopNode", "loop-b", data("循环 A")),
|
||||
node("loop-b", "loopNode", "loop-a", data("循环 B"))
|
||||
),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "LOOP_PARENT_CYCLE");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveShouldPassForValidDraft() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package tech.easyflow.ai.node;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* 临时文件 MultipartFile 适配测试。
|
||||
*/
|
||||
public class TemporaryFileMultipartFileTest {
|
||||
|
||||
/**
|
||||
* 验证文件流、大小及 transferTo 均复用磁盘内容。
|
||||
*
|
||||
* @throws Exception 临时文件读写失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldExposeTemporaryFileWithoutChangingContent() throws Exception {
|
||||
byte[] content = "workflow-streaming-file".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
Path source = Files.createTempFile("temporary-file-multipart-source-", ".txt");
|
||||
Path target = Files.createTempFile("temporary-file-multipart-target-", ".txt");
|
||||
try {
|
||||
Files.write(source, content);
|
||||
TemporaryFileMultipartFile file =
|
||||
new TemporaryFileMultipartFile("result.txt", source, "text/plain");
|
||||
|
||||
Assert.assertEquals(content.length, file.getSize());
|
||||
Assert.assertEquals("text/plain", file.getContentType());
|
||||
Assert.assertArrayEquals(content, file.getInputStream().readAllBytes());
|
||||
|
||||
file.transferTo(target.toFile());
|
||||
Assert.assertArrayEquals(content, Files.readAllBytes(target));
|
||||
} finally {
|
||||
Files.deleteIfExists(source);
|
||||
Files.deleteIfExists(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tech.easyflow.ai.utils;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* DocUtil 流式下载测试。
|
||||
*/
|
||||
public class DocUtilStreamingDownloadTest {
|
||||
|
||||
/**
|
||||
* 验证大响应按流落盘、内容完整且关闭后清理临时文件。
|
||||
*
|
||||
* @throws Exception 测试服务器或文件读取失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldStreamResponseToTemporaryFileAndCleanup() throws Exception {
|
||||
byte[] content = new byte[2 * 1024 * 1024 + 17];
|
||||
Arrays.fill(content, (byte) 7);
|
||||
HttpServer server = startServer(content);
|
||||
try {
|
||||
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/download";
|
||||
java.nio.file.Path path;
|
||||
try (DocUtil.DownloadedFile downloadedFile =
|
||||
DocUtil.downloadFileToTemp(url, content.length + 1L)) {
|
||||
path = downloadedFile.path();
|
||||
Assert.assertEquals(content.length, downloadedFile.size());
|
||||
Assert.assertEquals("application/octet-stream", downloadedFile.contentType());
|
||||
Assert.assertArrayEquals(content, Files.readAllBytes(path));
|
||||
}
|
||||
Assert.assertFalse(Files.exists(path));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证超过配置上限时显式失败。
|
||||
*
|
||||
* @throws Exception 测试服务器初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectResponseAboveConfiguredLimit() throws Exception {
|
||||
byte[] content = new byte[1024];
|
||||
HttpServer server = startServer(content);
|
||||
try {
|
||||
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/download";
|
||||
try {
|
||||
DocUtil.downloadFileToTemp(url, content.length - 1L);
|
||||
Assert.fail("expected download limit failure");
|
||||
} catch (RuntimeException exception) {
|
||||
Assert.assertTrue(exception.getCause().getMessage().contains("超过限制"));
|
||||
}
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动仅用于本测试的本地 HTTP 文件服务。
|
||||
*
|
||||
* @param content 响应内容
|
||||
* @return 已启动的 HTTP 服务
|
||||
* @throws Exception 服务创建失败时抛出
|
||||
*/
|
||||
private HttpServer startServer(byte[] content) throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/download", exchange -> {
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/octet-stream");
|
||||
exchange.sendResponseHeaders(200, content.length);
|
||||
try (OutputStream output = exchange.getResponseBody()) {
|
||||
for (int offset = 0; offset < content.length; offset += 8192) {
|
||||
int length = Math.min(8192, content.length - offset);
|
||||
output.write(content, offset, length);
|
||||
}
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package tech.easyflow.datacenter.connector.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTableField;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 项目 MySQL 连接器批量写入测试。
|
||||
*/
|
||||
public class ProjectMysqlConnectorBatchTest {
|
||||
|
||||
/**
|
||||
* 验证原始 SQL 在单连接、单 ResultSet 中原样流式消费。
|
||||
*
|
||||
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldStreamOriginalSqlWithoutPaginationRewrite()
|
||||
throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
PreparedStatement statement =
|
||||
mock(PreparedStatement.class);
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
ResultSetMetaData metadata =
|
||||
mock(ResultSetMetaData.class);
|
||||
String sql =
|
||||
"SELECT id FROM sample LIMIT 10 FOR UPDATE";
|
||||
when(dataSource.getConnection())
|
||||
.thenReturn(connection);
|
||||
when(connection.prepareStatement(
|
||||
eq(sql),
|
||||
eq(ResultSet.TYPE_FORWARD_ONLY),
|
||||
eq(ResultSet.CONCUR_READ_ONLY)))
|
||||
.thenReturn(statement);
|
||||
when(statement.executeQuery())
|
||||
.thenReturn(resultSet);
|
||||
when(resultSet.getMetaData())
|
||||
.thenReturn(metadata);
|
||||
when(metadata.getColumnCount()).thenReturn(1);
|
||||
when(metadata.getColumnLabel(1)).thenReturn("id");
|
||||
when(resultSet.next())
|
||||
.thenReturn(true, false);
|
||||
when(resultSet.getObject(1))
|
||||
.thenReturn(1L);
|
||||
List<String> ids = new ArrayList<>();
|
||||
|
||||
new ProjectMysqlConnector(dataSource)
|
||||
.consumeBySql(
|
||||
source(),
|
||||
sql,
|
||||
1_000,
|
||||
row -> ids.add(
|
||||
row.getString("id")));
|
||||
|
||||
org.junit.Assert.assertEquals(
|
||||
List.of("1"), ids);
|
||||
verify(dataSource).getConnection();
|
||||
verify(connection).prepareStatement(
|
||||
sql,
|
||||
ResultSet.TYPE_FORWARD_ONLY,
|
||||
ResultSet.CONCUR_READ_ONLY);
|
||||
verify(statement).setFetchSize(
|
||||
Integer.MIN_VALUE);
|
||||
verify(statement).executeQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证多行写入仅获取一次连接,并按批次执行 JDBC batch。
|
||||
*
|
||||
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldReuseSingleConnectionAndExecuteConfiguredBatches() throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
List<PreparedStatement> statements = new ArrayList<>();
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||
PreparedStatement statement = mock(PreparedStatement.class);
|
||||
statements.add(statement);
|
||||
return statement;
|
||||
});
|
||||
|
||||
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||
DatacenterSource source = new DatacenterSource();
|
||||
source.setDatabaseName("easyflow");
|
||||
DatacenterTable table = new DatacenterTable();
|
||||
table.setTableName("sample");
|
||||
DatacenterTableField nameField = new DatacenterTableField();
|
||||
nameField.setFieldName("name");
|
||||
nameField.setWritable(1);
|
||||
table.setFields(List.of(nameField));
|
||||
|
||||
List<JSONObject> rows = new ArrayList<>();
|
||||
for (int index = 0; index < 5; index++) {
|
||||
JSONObject row = new JSONObject();
|
||||
row.put("name", "row-" + index);
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
connector.saveRows(source, table, rows, null, 2);
|
||||
|
||||
verify(dataSource, times(1)).getConnection();
|
||||
if (statements.size() != 3) {
|
||||
throw new AssertionError("expected 3 JDBC batches but got " + statements.size());
|
||||
}
|
||||
int addBatchCalls = 0;
|
||||
for (PreparedStatement statement : statements) {
|
||||
verify(statement, times(1)).executeBatch();
|
||||
addBatchCalls += org.mockito.Mockito.mockingDetails(statement)
|
||||
.getInvocations()
|
||||
.stream()
|
||||
.filter(invocation -> "addBatch".equals(invocation.getMethod().getName()))
|
||||
.count();
|
||||
}
|
||||
if (addBatchCalls != rows.size()) {
|
||||
throw new AssertionError("expected " + rows.size() + " addBatch calls but got " + addBatchCalls);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证回执和业务批量写入在同一 JDBC 事务中提交。
|
||||
*
|
||||
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldCommitReceiptAndRowsInSingleTransaction() throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
PreparedStatement receiptStatement = mock(PreparedStatement.class);
|
||||
PreparedStatement queryStatement = mock(PreparedStatement.class);
|
||||
PreparedStatement rowStatement = mock(PreparedStatement.class);
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.getAutoCommit()).thenReturn(true);
|
||||
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||
String sql = invocation.getArgument(0);
|
||||
if (sql.startsWith("SELECT")) {
|
||||
return queryStatement;
|
||||
}
|
||||
return sql.contains("tb_datacenter_write_receipt")
|
||||
? receiptStatement
|
||||
: rowStatement;
|
||||
});
|
||||
when(queryStatement.executeQuery()).thenReturn(resultSet);
|
||||
when(resultSet.next()).thenReturn(false);
|
||||
|
||||
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||
DatacenterSource source = source();
|
||||
DatacenterTable table = table();
|
||||
JSONObject row = new JSONObject();
|
||||
row.put("name", "row-1");
|
||||
|
||||
boolean written = connector.saveRowsIdempotently(
|
||||
source,
|
||||
table,
|
||||
List.of(row),
|
||||
null,
|
||||
100,
|
||||
"receipt-key",
|
||||
"payload-hash");
|
||||
|
||||
assertTrue(written);
|
||||
verify(connection).setAutoCommit(false);
|
||||
verify(receiptStatement).executeBatch();
|
||||
verify(receiptStatement).executeUpdate();
|
||||
verify(rowStatement).executeBatch();
|
||||
verify(connection, times(2)).commit();
|
||||
verify(connection, never()).rollback();
|
||||
verify(connection).setAutoCommit(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证业务批量失败时回执与业务数据一并回滚。
|
||||
*
|
||||
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldRollbackReceiptWhenBatchWriteFails() throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
PreparedStatement receiptStatement = mock(PreparedStatement.class);
|
||||
PreparedStatement queryStatement = mock(PreparedStatement.class);
|
||||
PreparedStatement rowStatement = mock(PreparedStatement.class);
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.getAutoCommit()).thenReturn(true);
|
||||
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||
String sql = invocation.getArgument(0);
|
||||
if (sql.startsWith("SELECT")) {
|
||||
return queryStatement;
|
||||
}
|
||||
return sql.contains("tb_datacenter_write_receipt")
|
||||
? receiptStatement
|
||||
: rowStatement;
|
||||
});
|
||||
when(queryStatement.executeQuery()).thenReturn(resultSet);
|
||||
when(resultSet.next()).thenReturn(false);
|
||||
when(rowStatement.executeBatch()).thenThrow(new SQLException("write failed"));
|
||||
|
||||
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||
JSONObject row = new JSONObject();
|
||||
row.put("name", "row-1");
|
||||
|
||||
try {
|
||||
connector.saveRowsIdempotently(
|
||||
source(),
|
||||
table(),
|
||||
List.of(row),
|
||||
null,
|
||||
100,
|
||||
"receipt-key",
|
||||
"payload-hash");
|
||||
throw new AssertionError("failed business batch must rollback");
|
||||
} catch (BusinessException expected) {
|
||||
assertTrue(expected.getMessage().contains("write failed"));
|
||||
}
|
||||
|
||||
verify(connection, atLeastOnce()).rollback();
|
||||
verify(connection, never()).commit();
|
||||
verify(connection).setAutoCommit(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证中间行失败时前序行已经提交,后续行不会执行。
|
||||
*
|
||||
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepEarlierRowsCommittedWhenMiddleRowFails() throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
PreparedStatement receiptStatement = mock(PreparedStatement.class);
|
||||
PreparedStatement queryStatement = mock(PreparedStatement.class);
|
||||
PreparedStatement rowStatement = mock(PreparedStatement.class);
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.getAutoCommit()).thenReturn(true);
|
||||
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||
String sql = invocation.getArgument(0);
|
||||
if (sql.startsWith("SELECT")) {
|
||||
return queryStatement;
|
||||
}
|
||||
return sql.contains("tb_datacenter_write_receipt")
|
||||
? receiptStatement
|
||||
: rowStatement;
|
||||
});
|
||||
when(queryStatement.executeQuery()).thenReturn(resultSet);
|
||||
when(resultSet.next()).thenReturn(false);
|
||||
when(rowStatement.executeBatch())
|
||||
.thenThrow(new SQLException("batch failed"))
|
||||
.thenReturn(new int[]{1})
|
||||
.thenThrow(new SQLException("middle row failed"));
|
||||
|
||||
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||
List<JSONObject> rows = List.of(
|
||||
row("row-0"), row("row-1"), row("row-2"));
|
||||
|
||||
try {
|
||||
connector.saveRowsIdempotently(
|
||||
source(),
|
||||
table(),
|
||||
rows,
|
||||
null,
|
||||
100,
|
||||
"receipt-key",
|
||||
"payload-hash");
|
||||
throw new AssertionError("middle row failure must be propagated");
|
||||
} catch (BusinessException expected) {
|
||||
assertTrue(expected.getMessage().contains("middle row failed"));
|
||||
}
|
||||
|
||||
verify(dataSource, times(1)).getConnection();
|
||||
verify(connection, times(1)).commit();
|
||||
verify(connection, atLeastOnce()).rollback();
|
||||
verify(rowStatement, times(3)).executeBatch();
|
||||
verify(receiptStatement).executeBatch();
|
||||
verify(receiptStatement, times(2)).executeUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试数据源元数据。
|
||||
*
|
||||
* @return 项目 MySQL 数据源
|
||||
*/
|
||||
private DatacenterSource source() {
|
||||
DatacenterSource source = new DatacenterSource();
|
||||
source.setDatabaseName("easyflow");
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含一个可写字段的测试表。
|
||||
*
|
||||
* @return 测试数据表
|
||||
*/
|
||||
private DatacenterTable table() {
|
||||
DatacenterTable table = new DatacenterTable();
|
||||
table.setTableName("sample");
|
||||
DatacenterTableField nameField = new DatacenterTableField();
|
||||
nameField.setFieldName("name");
|
||||
nameField.setWritable(1);
|
||||
table.setFields(List.of(nameField));
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试数据行。
|
||||
*
|
||||
* @param name 行名称
|
||||
* @return JSON 行
|
||||
*/
|
||||
private JSONObject row(String name) {
|
||||
JSONObject row = new JSONObject();
|
||||
row.put("name", name);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package tech.easyflow.datacenter.connector.support;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.mybatisflex.core.row.Db;
|
||||
import com.mybatisflex.core.row.Row;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTableField;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
||||
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
/**
|
||||
* 内部动态表幂等批量写入回归测试。
|
||||
*/
|
||||
public class AbstractInternalTableConnectorBatchTest {
|
||||
|
||||
/**
|
||||
* 验证正常路径按批次写入回执和数据,不退化为逐行 SQL。
|
||||
*/
|
||||
@Test
|
||||
public void shouldBatchReceiptsAndRowsOnNormalPath() {
|
||||
DatacenterTable table = mock(DatacenterTable.class);
|
||||
DatacenterTableField field =
|
||||
mock(DatacenterTableField.class);
|
||||
org.mockito.Mockito.when(table.getFields())
|
||||
.thenReturn(List.of(field));
|
||||
org.mockito.Mockito.when(table.getMaterializedTable())
|
||||
.thenReturn("tb_internal_test");
|
||||
org.mockito.Mockito.when(field.getFieldName())
|
||||
.thenReturn("name");
|
||||
LoginAccount account = mock(LoginAccount.class);
|
||||
org.mockito.Mockito.when(account.getId())
|
||||
.thenReturn(BigInteger.ONE);
|
||||
org.mockito.Mockito.when(account.getDeptId())
|
||||
.thenReturn(BigInteger.ONE);
|
||||
org.mockito.Mockito.when(account.getTenantId())
|
||||
.thenReturn(BigInteger.ONE);
|
||||
JSONObject first = JSONObject.of("name", "first");
|
||||
JSONObject second = JSONObject.of("name", "second");
|
||||
|
||||
try (MockedStatic<Db> db = mockStatic(Db.class)) {
|
||||
db.when(() -> Db.selectOneByMap(
|
||||
eq("tb_datacenter_write_receipt"),
|
||||
anyMap()))
|
||||
.thenReturn(null);
|
||||
db.when(() -> Db.txWithResult(
|
||||
org.mockito.ArgumentMatchers
|
||||
.<Supplier<Object>>any()))
|
||||
.thenAnswer(invocation -> invocation
|
||||
.<Supplier<?>>getArgument(0)
|
||||
.get());
|
||||
|
||||
boolean written = new TestInternalConnector()
|
||||
.saveRowsIdempotently(
|
||||
new DatacenterSource(),
|
||||
table,
|
||||
List.of(first, second),
|
||||
account,
|
||||
2,
|
||||
"receipt",
|
||||
"hash");
|
||||
|
||||
Assert.assertTrue(written);
|
||||
db.verify(() -> Db.insertBatch(
|
||||
eq("tb_datacenter_write_receipt"),
|
||||
anyCollection(),
|
||||
eq(2)));
|
||||
db.verify(() -> Db.insertBatch(
|
||||
eq("tb_internal_test"),
|
||||
anyCollection(),
|
||||
eq(2)));
|
||||
db.verify(() -> Db.updateBatchById(
|
||||
eq("tb_internal_test"),
|
||||
anyList()), never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅用于测试内部动态表批量协议的最小连接器。
|
||||
*/
|
||||
private static final class TestInternalConnector
|
||||
extends AbstractInternalTableConnector {
|
||||
|
||||
/**
|
||||
* 创建测试连接器。
|
||||
*/
|
||||
private TestInternalConnector() {
|
||||
super(
|
||||
DatacenterSourceType.EXCEL,
|
||||
Collections.<DatacenterCapability>emptySet(),
|
||||
mock(DataSource.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package tech.easyflow.datacenter.connector.support;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.datacenter.connector.dialect.PostgresqlSqlDialect;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
||||
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.util.EnumSet;
|
||||
|
||||
/**
|
||||
* PostgreSQL 服务端游标连接状态回归测试。
|
||||
*/
|
||||
public class PostgresqlStreamingConnectorTest {
|
||||
|
||||
/**
|
||||
* 验证自动提交连接进入游标事务并在消费完成后恢复。
|
||||
*
|
||||
* @throws Exception JDBC 模拟调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldRestoreAutoCommitAfterStreaming()
|
||||
throws Exception {
|
||||
Connection connection =
|
||||
Mockito.mock(Connection.class);
|
||||
PreparedStatement statement =
|
||||
Mockito.mock(
|
||||
PreparedStatement.class);
|
||||
ResultSet resultSet =
|
||||
Mockito.mock(ResultSet.class);
|
||||
ResultSetMetaData metaData =
|
||||
Mockito.mock(
|
||||
ResultSetMetaData.class);
|
||||
Mockito.when(connection.getAutoCommit())
|
||||
.thenReturn(true);
|
||||
Mockito.when(connection.prepareStatement(
|
||||
"SELECT id FROM sample",
|
||||
ResultSet.TYPE_FORWARD_ONLY,
|
||||
ResultSet.CONCUR_READ_ONLY))
|
||||
.thenReturn(statement);
|
||||
Mockito.when(statement.executeQuery())
|
||||
.thenReturn(resultSet);
|
||||
Mockito.when(resultSet.getMetaData())
|
||||
.thenReturn(metaData);
|
||||
Mockito.when(resultSet.next())
|
||||
.thenReturn(false);
|
||||
TestConnector connector =
|
||||
new TestConnector(connection);
|
||||
|
||||
connector.consumeBySql(
|
||||
new DatacenterSource(),
|
||||
"SELECT id FROM sample",
|
||||
512,
|
||||
row -> {
|
||||
});
|
||||
|
||||
InOrder order = Mockito.inOrder(
|
||||
connection,
|
||||
statement,
|
||||
resultSet);
|
||||
order.verify(connection)
|
||||
.getAutoCommit();
|
||||
order.verify(connection)
|
||||
.setAutoCommit(false);
|
||||
order.verify(connection)
|
||||
.prepareStatement(
|
||||
"SELECT id FROM sample",
|
||||
ResultSet.TYPE_FORWARD_ONLY,
|
||||
ResultSet.CONCUR_READ_ONLY);
|
||||
order.verify(statement)
|
||||
.setFetchSize(512);
|
||||
order.verify(statement)
|
||||
.executeQuery();
|
||||
order.verify(resultSet)
|
||||
.close();
|
||||
order.verify(statement)
|
||||
.close();
|
||||
order.verify(connection)
|
||||
.rollback();
|
||||
order.verify(connection)
|
||||
.setAutoCommit(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用测试连接执行 PostgreSQL 查询。
|
||||
*/
|
||||
private static final class TestConnector
|
||||
extends AbstractJdbcConnector {
|
||||
|
||||
private final Connection connection;
|
||||
|
||||
/**
|
||||
* 创建测试连接器。
|
||||
*
|
||||
* @param connection 测试 JDBC 连接
|
||||
*/
|
||||
private TestConnector(
|
||||
Connection connection) {
|
||||
super(
|
||||
DatacenterSourceType.POSTGRESQL,
|
||||
new PostgresqlSqlDialect(),
|
||||
EnumSet.of(
|
||||
DatacenterCapability.READ_QUERY));
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected <T> T withConnection(
|
||||
DatacenterSource source,
|
||||
boolean cacheable,
|
||||
JdbcCallback<T> callback)
|
||||
throws Exception {
|
||||
return callback.apply(connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package tech.easyflow.datacenter.execution.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.common.cache.RedisIdempotencyExecutor;
|
||||
import tech.easyflow.datacenter.connector.DatacenterConnector;
|
||||
import tech.easyflow.datacenter.connector.DatacenterConnectorRegistry;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 数据集写入服务的幂等批写测试。
|
||||
*/
|
||||
public class DatacenterDatasetWriteServiceImplTest {
|
||||
|
||||
/**
|
||||
* 验证服务只调用一次连接器,并保留配置的批大小供连接器复用连接处理。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldDelegateIdempotentRowsInSingleConnectorCall() throws Exception {
|
||||
DatacenterDatasetRegistryService registryService =
|
||||
mock(DatacenterDatasetRegistryService.class);
|
||||
DatacenterConnectorRegistry connectorRegistry =
|
||||
mock(DatacenterConnectorRegistry.class);
|
||||
DatacenterConnector connector = mock(DatacenterConnector.class);
|
||||
RedisIdempotencyExecutor idempotencyExecutor =
|
||||
mock(RedisIdempotencyExecutor.class);
|
||||
|
||||
BigInteger tableId = BigInteger.ONE;
|
||||
BigInteger sourceId = BigInteger.TWO;
|
||||
DatasetRef datasetRef = new DatasetRef();
|
||||
datasetRef.setTableId(tableId);
|
||||
DatacenterTable table = new DatacenterTable();
|
||||
table.setSourceId(sourceId);
|
||||
DatacenterSource source = new DatacenterSource();
|
||||
source.setSourceType(DatacenterSourceType.PROJECT_MYSQL.name());
|
||||
when(registryService.getTableWithFields(tableId)).thenReturn(table);
|
||||
when(registryService.getSourceRequired(sourceId)).thenReturn(source);
|
||||
when(connectorRegistry.getConnector(
|
||||
DatacenterSourceType.PROJECT_MYSQL.name())).thenReturn(connector);
|
||||
when(idempotencyExecutor.executeOnce(
|
||||
anyString(), anyString(), any(Runnable.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
invocation.<Runnable>getArgument(2).run();
|
||||
return true;
|
||||
});
|
||||
when(connector.saveRowsIdempotently(
|
||||
any(), any(), anyList(), any(), anyInt(), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
|
||||
DatacenterDatasetWriteServiceImpl service =
|
||||
new DatacenterDatasetWriteServiceImpl();
|
||||
inject(service, "registryService", registryService);
|
||||
inject(service, "connectorRegistry", connectorRegistry);
|
||||
inject(service, "idempotencyExecutor", idempotencyExecutor);
|
||||
List<JSONObject> rows = List.of(
|
||||
row("row-0"), row("row-1"), row("row-2"));
|
||||
|
||||
Assert.assertTrue(service.saveRowsIdempotently(
|
||||
datasetRef, rows, null, 64, "stable-execution-key"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<JSONObject>> rowsCaptor =
|
||||
ArgumentCaptor.forClass(List.class);
|
||||
verify(connector, times(1)).saveRowsIdempotently(
|
||||
eq(source),
|
||||
eq(table),
|
||||
rowsCaptor.capture(),
|
||||
any(),
|
||||
eq(64),
|
||||
anyString(),
|
||||
anyString());
|
||||
Assert.assertEquals(rows, rowsCaptor.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试行。
|
||||
*
|
||||
* @param name 行名称
|
||||
* @return JSON 行
|
||||
*/
|
||||
private JSONObject row(String name) {
|
||||
JSONObject row = new JSONObject();
|
||||
row.put("name", name);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入服务测试依赖。
|
||||
*
|
||||
* @param target 目标服务
|
||||
* @param fieldName 字段名
|
||||
* @param value 字段值
|
||||
* @throws Exception 反射访问失败时抛出
|
||||
*/
|
||||
private void inject(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tech.easyflow.datacenter.schedule;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 数据集写入回执清理任务测试。
|
||||
*/
|
||||
public class DatacenterWriteReceiptCleanupJobTest {
|
||||
|
||||
/**
|
||||
* 验证清理任务按固定大小分批,并在最后一个非满批次后停止。
|
||||
*/
|
||||
@Test
|
||||
public void shouldDeleteExpiredReceiptsInBoundedBatches() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
when(jdbcTemplate.update(anyString(), any(), anyInt()))
|
||||
.thenReturn(1000, 7);
|
||||
DatacenterWriteReceiptCleanupJob job =
|
||||
new DatacenterWriteReceiptCleanupJob(
|
||||
jdbcTemplate, 14L, 1000, 20);
|
||||
|
||||
job.cleanup();
|
||||
|
||||
verify(jdbcTemplate, times(2)).update(
|
||||
anyString(), any(), anyInt());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user