perf: 收敛工作流状态与高 IO 节点开销
- 落地 Redis 版本状态、触发租约和定义缓存 - 优化数据批写、插件请求、文件下载与审计日志 - 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* {@link RedisIdempotencyExecutor} 状态转换回归测试。
|
||||
*/
|
||||
public class RedisIdempotencyExecutorTest {
|
||||
|
||||
/**
|
||||
* 验证存在测试构造器时生产构造器仍能被 Spring 明确选择。
|
||||
*
|
||||
* @throws Exception 生产构造器不存在时抛出
|
||||
*/
|
||||
@Test
|
||||
public void productionConstructorShouldBeAutowired()
|
||||
throws Exception {
|
||||
Constructor<RedisIdempotencyExecutor> constructor =
|
||||
RedisIdempotencyExecutor.class.getConstructor(
|
||||
StringRedisTemplate.class);
|
||||
|
||||
Assert.assertNotNull(
|
||||
constructor.getAnnotation(Autowired.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证首次认领会执行操作并写入完成凭证。
|
||||
*/
|
||||
@Test
|
||||
public void executeOnceShouldRunAndCompleteForNewKey() {
|
||||
StringRedisTemplate redisTemplate = redisTemplateReturning(1L, 1L);
|
||||
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||
AtomicInteger executions = new AtomicInteger();
|
||||
|
||||
boolean executed = executor.executeOnce(
|
||||
"workflow:instance:node:trigger", executions::incrementAndGet);
|
||||
|
||||
Assert.assertTrue(executed);
|
||||
Assert.assertEquals(1, executions.get());
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证已有完成凭证时直接跳过副作用操作。
|
||||
*/
|
||||
@Test
|
||||
public void executeOnceShouldSkipCompletedKey() {
|
||||
StringRedisTemplate redisTemplate = redisTemplateReturning(2L);
|
||||
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||
AtomicInteger executions = new AtomicInteger();
|
||||
|
||||
boolean executed = executor.executeOnce(
|
||||
"workflow:instance:node:trigger", executions::incrementAndGet);
|
||||
|
||||
Assert.assertFalse(executed);
|
||||
Assert.assertEquals(0, executions.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证其他 owner 仍在处理时返回明确冲突。
|
||||
*/
|
||||
@Test
|
||||
public void executeOnceShouldRejectInProgressKey() {
|
||||
StringRedisTemplate redisTemplate = redisTemplateReturning(0L);
|
||||
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||
|
||||
try {
|
||||
executor.executeOnce("workflow:instance:node:trigger", () -> {
|
||||
});
|
||||
Assert.fail("in-progress operation must be rejected");
|
||||
} catch (RedisIdempotencyExecutor.IdempotentOperationInProgressException expected) {
|
||||
Assert.assertTrue(expected.getMessage().contains("workflow:instance"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一幂等键绑定不同负载时明确拒绝,避免把不同业务写入误判为已完成。
|
||||
*/
|
||||
@Test
|
||||
public void executeOnceShouldRejectPayloadMismatch() {
|
||||
StringRedisTemplate redisTemplate = redisTemplateReturning(-1L);
|
||||
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||
|
||||
try {
|
||||
executor.executeOnce(
|
||||
"workflow:instance:node:trigger",
|
||||
"different-payload-hash",
|
||||
() -> {
|
||||
});
|
||||
Assert.fail("payload mismatch must be rejected");
|
||||
} catch (RedisIdempotencyExecutor.IdempotencyPayloadMismatchException expected) {
|
||||
Assert.assertTrue(expected.getMessage().contains("workflow:instance"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证长操作会在处理中凭证到期前持续续期。
|
||||
*
|
||||
* @throws Exception 测试等待被中断
|
||||
*/
|
||||
@Test
|
||||
public void executeOnceShouldRenewLeaseForLongOperation()
|
||||
throws Exception {
|
||||
StringRedisTemplate redisTemplate =
|
||||
redisTemplateReturning(1L, 1L);
|
||||
Mockito.when(redisTemplate.execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString()))
|
||||
.thenReturn(1L);
|
||||
RedisIdempotencyExecutor executor =
|
||||
new RedisIdempotencyExecutor(
|
||||
redisTemplate,
|
||||
Duration.ofMillis(60L),
|
||||
Duration.ofMinutes(1L));
|
||||
|
||||
Assert.assertTrue(executor.executeOnce(
|
||||
"workflow:long-operation",
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(90L);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(
|
||||
"test interrupted", exception);
|
||||
}
|
||||
}));
|
||||
|
||||
Mockito.verify(redisTemplate, Mockito.atLeastOnce()).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建按顺序返回脚本结果的 Redis 模板。
|
||||
*
|
||||
* @param results 脚本返回值
|
||||
* @return Redis 模板
|
||||
*/
|
||||
private StringRedisTemplate redisTemplateReturning(Long... results) {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
Mockito.when(redisTemplate.execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString()
|
||||
)).thenReturn(results[0]);
|
||||
Mockito.when(redisTemplate.execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString()
|
||||
)).thenReturn(results[Math.min(1, results.length - 1)]);
|
||||
return redisTemplate;
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,74 @@ public class RedisLockExecutorTest {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 fencing token 通过 Redis 原子脚本分配并返回。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void nextFencingTokenShouldReturnAtomicSequenceValue() throws Exception {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
Mockito.when(redisTemplate.execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.<Object[]>any()
|
||||
)).thenReturn(9L);
|
||||
|
||||
RedisLockExecutor executor = new RedisLockExecutor();
|
||||
setRedisTemplate(executor, redisTemplate);
|
||||
|
||||
long token = executor.nextFencingToken(
|
||||
"workflowState:{instance}:fence",
|
||||
Duration.ofDays(4));
|
||||
|
||||
Assert.assertEquals(9L, token);
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.eq(List.of("workflowState:{instance}:fence")),
|
||||
ArgumentMatchers.eq(String.valueOf(Duration.ofDays(4).toMillis()))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证互斥锁与 fencing token 通过同一个 Redis 脚本原子获取。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void tryAcquireFencedShouldSetLockAndAdvanceTokenAtomically() throws Exception {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
Mockito.when(redisTemplate.execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString()
|
||||
)).thenReturn(17L);
|
||||
|
||||
RedisLockExecutor executor = new RedisLockExecutor();
|
||||
setRedisTemplate(executor, redisTemplate);
|
||||
|
||||
RedisLockExecutor.LockHandle handle = executor.tryAcquireFenced(
|
||||
"chainLock:{instance}",
|
||||
"workflowState:{instance}:fence",
|
||||
Duration.ZERO,
|
||||
Duration.ofSeconds(30),
|
||||
Duration.ofDays(4));
|
||||
|
||||
Assert.assertNotNull(handle);
|
||||
Assert.assertEquals(17L, handle.getFencingToken());
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.eq(List.of(
|
||||
"chainLock:{instance}",
|
||||
"workflowState:{instance}:fence")),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.eq("30000"),
|
||||
ArgumentMatchers.eq(
|
||||
String.valueOf(Duration.ofDays(4).toMillis())));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
|
||||
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import com.alicp.jetcache.support.JavaValueEncoder;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.RedisHashCommands;
|
||||
import org.springframework.data.redis.connection.RedisScriptingCommands;
|
||||
import org.springframework.data.redis.connection.ReturnType;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link RedisVersionedObjectStore} 二进制协议与脚本调用回归测试。
|
||||
*/
|
||||
public class RedisVersionedObjectStoreTest {
|
||||
|
||||
/**
|
||||
* 验证状态对象继续按项目 Java 序列化协议读取。
|
||||
*/
|
||||
@Test
|
||||
public void loadShouldDecodeExistingJavaPayload() {
|
||||
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
|
||||
RedisConnection connection = Mockito.mock(RedisConnection.class);
|
||||
RedisHashCommands hashCommands = Mockito.mock(RedisHashCommands.class);
|
||||
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
|
||||
Mockito.when(connection.hashCommands()).thenReturn(hashCommands);
|
||||
Mockito.when(hashCommands.hGet(
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class)
|
||||
)).thenReturn(JavaValueEncoder.INSTANCE.apply("state-value"));
|
||||
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
|
||||
|
||||
String loaded = store.load("workflowState:{instance}:chain", String.class);
|
||||
|
||||
Assert.assertEquals("state-value", loaded);
|
||||
Mockito.verify(connection).close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 CAS 更新通过单次 Redis Lua 调用完成。
|
||||
*/
|
||||
@Test
|
||||
public void compareAndSetShouldUseSingleAtomicScript() {
|
||||
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
|
||||
RedisConnection connection = Mockito.mock(RedisConnection.class);
|
||||
RedisScriptingCommands scriptingCommands = Mockito.mock(RedisScriptingCommands.class);
|
||||
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
|
||||
Mockito.when(connection.scriptingCommands()).thenReturn(scriptingCommands);
|
||||
Mockito.when(scriptingCommands.eval(
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||
ArgumentMatchers.eq(1),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class)
|
||||
)).thenReturn(1L);
|
||||
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
|
||||
|
||||
boolean updated = store.compareAndSet(
|
||||
"workflowState:{instance}:chain",
|
||||
3L,
|
||||
"state-value",
|
||||
4L,
|
||||
Duration.ofDays(3));
|
||||
|
||||
Assert.assertTrue(updated);
|
||||
Mockito.verify(scriptingCommands, Mockito.times(1)).eval(
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||
ArgumentMatchers.eq(1),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class));
|
||||
Mockito.verify(connection).close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点状态更新在同一脚本内校验链版本和 fencing token。
|
||||
*/
|
||||
@Test
|
||||
public void compareAndSetFieldsShouldUseDoubleGuardedAtomicScript() {
|
||||
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
|
||||
RedisConnection connection = Mockito.mock(RedisConnection.class);
|
||||
RedisScriptingCommands scriptingCommands = Mockito.mock(RedisScriptingCommands.class);
|
||||
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
|
||||
Mockito.when(connection.scriptingCommands()).thenReturn(scriptingCommands);
|
||||
Mockito.when(scriptingCommands.eval(
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||
ArgumentMatchers.eq(3),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class)
|
||||
)).thenReturn(1L);
|
||||
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
|
||||
|
||||
boolean updated = store.compareAndSetFields(
|
||||
"workflowState:{instance}:node:node-1",
|
||||
2L,
|
||||
Map.of("status", "RUNNING"),
|
||||
3L,
|
||||
"workflowState:{instance}:chain",
|
||||
8L,
|
||||
"workflowState:{instance}:fence",
|
||||
12L,
|
||||
Duration.ofDays(3));
|
||||
|
||||
Assert.assertTrue(updated);
|
||||
Mockito.verify(scriptingCommands).eval(
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||
ArgumentMatchers.eq(3),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class),
|
||||
ArgumentMatchers.any(byte[].class));
|
||||
Mockito.verify(connection).close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user