feat: 切换定时任务至分布式调度底座

- 以执行账本和有界 Worker 承载重负载任务与故障接管

- 接入统一调度 Starter 并增加 MySQL 迁移、指标和回归测试
This commit is contained in:
2026-08-31 14:57:08 +08:00
parent 17ef189862
commit 8c174e5c02
66 changed files with 5348 additions and 545 deletions

View File

@@ -11,6 +11,9 @@ import org.springframework.data.redis.core.script.RedisScript;
import java.lang.reflect.Field;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link RedisLockExecutor} 回归测试。
@@ -147,6 +150,96 @@ public class RedisLockExecutorTest {
String.valueOf(Duration.ofDays(4).toMillis())));
}
@Test
public void renewingLockShouldRenewBeforeLongRunningCommandCompletes() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(true);
CountDownLatch renewed = new CountDownLatch(1);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenAnswer(invocation -> {
renewed.countDown();
return 1L;
});
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
try {
executor.executeWithRenewingLock(
"easyflow:test:renewing-lock",
Duration.ZERO,
Duration.ofMillis(60),
() -> {
try {
Assert.assertTrue(renewed.await(1, TimeUnit.SECONDS));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError("等待锁续租时被中断", exception);
}
});
} finally {
executor.shutdownLockRenewalExecutor();
}
Mockito.verify(redisTemplate, Mockito.atLeastOnce()).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.eq(List.of("easyflow:test:renewing-lock")),
ArgumentMatchers.anyString(),
ArgumentMatchers.eq("60"));
}
@Test
public void renewingLockMustNotReturnSuccessAfterRenewalThrows() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(true);
CountDownLatch renewalAttempted = new CountDownLatch(1);
AtomicInteger scriptCalls = new AtomicInteger();
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenAnswer(invocation -> {
if (scriptCalls.incrementAndGet() == 1) {
renewalAttempted.countDown();
throw new IllegalStateException("redis unavailable");
}
return 1L;
});
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
try {
try {
executor.executeWithRenewingLock(
"easyflow:test:renewal-failure",
Duration.ZERO,
Duration.ofMillis(60),
() -> {
try {
Assert.assertTrue(renewalAttempted.await(1, TimeUnit.SECONDS));
// 等待续租线程把失败结果发布到调用线程;业务任务与续租
// 同时完成时,锁仍处于原租约内且 callback 已结束。
Thread.sleep(50L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError(exception);
}
});
Assert.fail("续租异常后不应返回成功");
} catch (IllegalStateException exception) {
Assert.assertTrue(exception.getMessage().contains("分布式锁已丢失"));
}
} finally {
executor.shutdownLockRenewalExecutor();
}
}
@SuppressWarnings("unchecked")
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);