perf: 收敛工作流状态与高 IO 节点开销

- 落地 Redis 版本状态、触发租约和定义缓存

- 优化数据批写、插件请求、文件下载与审计日志

- 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
2026-07-29 00:47:48 +08:00
parent 5ee6065017
commit 1ae8a22afe
103 changed files with 15600 additions and 532 deletions

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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();
}
}