feat: 增强多实例分布式部署兼容

- 增加定时任务分布式锁并覆盖 chatlog、文档导入和 Agent HITL 过期扫描

- 增强 Redis MQ 多实例 consumer 标识、pending reclaim 和单条处理能力

- 增加文档导入状态 Redis 广播和 Agent HITL 跨节点路由确认
This commit is contained in:
2026-05-29 18:27:46 +08:00
parent cc3bb9cff0
commit 0f4d10c43c
39 changed files with 2703 additions and 17 deletions

View File

@@ -131,5 +131,11 @@
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -5,11 +5,13 @@ import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
import tech.easyflow.ai.documentimport.task.DocumentImportParseMonitorProperties;
import tech.easyflow.ai.documentimport.task.DocumentImportStatusBroadcastProperties;
@MapperScan("tech.easyflow.ai.mapper")
@ComponentScan("tech.easyflow.ai")
@EnableConfigurationProperties({
DocumentImportParseMonitorProperties.class,
DocumentImportStatusBroadcastProperties.class,
RagHealthProperties.class
})
@AutoConfiguration

View File

@@ -2,6 +2,7 @@ package tech.easyflow.ai.documentimport.task;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.DistributedScheduledLock;
/**
* 知识库文档解析任务收敛器。
@@ -27,6 +28,7 @@ public class DocumentImportParseMonitor {
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:10000}",
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:10000}"
)
@DistributedScheduledLock(key = "easyflow:schedule:document-import:parse-monitor", leaseSeconds = 300L)
public void reconcileRunningParseTasks() {
appService.monitorRunningParseTasks();
}

View File

@@ -0,0 +1,79 @@
package tech.easyflow.ai.documentimport.task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
/**
* 文档导入状态 Redis 广播配置。
*/
@Configuration
public class DocumentImportStatusBroadcastConfig {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportStatusBroadcastConfig.class);
/**
* 创建文档导入状态广播监听容器。
*
* @param connectionFactory Redis 连接工厂
* @param streamService 文档导入状态流服务
* @param properties 文档导入监控配置
* @return Redis 消息监听容器
*/
@Bean
public RedisMessageListenerContainer documentImportStatusListenerContainer(
RedisConnectionFactory connectionFactory,
DocumentImportTaskStatusStreamService streamService,
DocumentImportStatusBroadcastProperties properties
) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(
new DocumentImportStatusMessageListener(streamService),
new ChannelTopic(properties.getStatusBroadcastChannel())
);
return container;
}
/**
* 文档导入状态广播监听器。
*/
private static final class DocumentImportStatusMessageListener implements MessageListener {
private final DocumentImportTaskStatusStreamService streamService;
/**
* 创建监听器。
*
* @param streamService 文档导入状态流服务
*/
private DocumentImportStatusMessageListener(DocumentImportTaskStatusStreamService streamService) {
this.streamService = streamService;
}
/**
* 处理 Redis 广播消息。
*
* @param message 消息
* @param pattern 订阅模式
*/
@Override
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody(), StandardCharsets.UTF_8);
try {
streamService.publishLocal(new BigInteger(payload));
} catch (RuntimeException e) {
LOG.warn("处理文档导入状态广播失败: payload={}", payload, e);
}
}
}
}

View File

@@ -0,0 +1,34 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 文档导入状态广播配置。
*/
@ConfigurationProperties(prefix = "easyflow.ai.document-import")
public class DocumentImportStatusBroadcastProperties {
private String statusBroadcastChannel = "easyflow:document-import:status";
/**
* 获取文档导入状态广播通道。
*
* @return Redis 广播通道
*/
public String getStatusBroadcastChannel() {
return statusBroadcastChannel;
}
/**
* 设置文档导入状态广播通道。
*
* @param statusBroadcastChannel Redis 广播通道
*/
public void setStatusBroadcastChannel(String statusBroadcastChannel) {
if (statusBroadcastChannel == null || statusBroadcastChannel.trim().isEmpty()) {
this.statusBroadcastChannel = "easyflow:document-import:status";
return;
}
this.statusBroadcastChannel = statusBroadcastChannel.trim();
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.http.MediaType;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -43,6 +44,12 @@ public class DocumentImportTaskStatusStreamService {
@Resource(name = "sseThreadPool")
private ThreadPoolTaskExecutor sseThreadPool;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private DocumentImportStatusBroadcastProperties statusBroadcastProperties;
/**
* 订阅知识库文档任务状态流。
*
@@ -75,7 +82,7 @@ public class DocumentImportTaskStatusStreamService {
if (documentId == null) {
return;
}
Runnable publishAction = () -> publishNow(documentId);
Runnable publishAction = () -> publishStatusChange(documentId);
if (TransactionSynchronizationManager.isSynchronizationActive()
&& TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@@ -89,7 +96,22 @@ public class DocumentImportTaskStatusStreamService {
publishAction.run();
}
private void publishNow(BigInteger documentId) {
/**
* 处理 Redis 广播收到的文档状态变更。
*
* @param documentId 文档 ID
*/
public void publishLocal(BigInteger documentId) {
publishNow(documentId);
}
private void publishStatusChange(BigInteger documentId) {
// 先推送本机连接,降低单机部署和广播链路延迟。
publishNow(documentId);
stringRedisTemplate.convertAndSend(statusBroadcastProperties.getStatusBroadcastChannel(), documentId.toString());
}
void publishNow(BigInteger documentId) {
Document document = documentMapper.selectOneById(documentId);
if (document == null || document.getCollectionId() == null) {
return;

View File

@@ -0,0 +1,97 @@
package tech.easyflow.ai.documentimport.task;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.mapper.DocumentMapper;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* {@link DocumentImportTaskStatusStreamService} 回归测试。
*/
public class DocumentImportTaskStatusStreamServiceTest {
/**
* 验证文档状态变更会向 Redis 广播文档 ID。
*
* @throws Exception 反射注入异常
*/
@Test
public void publishAfterCommitShouldBroadcastDocumentId() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
DocumentImportTaskStatusStreamService service = new DocumentImportTaskStatusStreamService();
setField(service, "documentMapper", mockDocumentMapper());
setField(service, "sseThreadPool", directExecutor());
setField(service, "stringRedisTemplate", redisTemplate);
setField(service, "statusBroadcastProperties", statusBroadcastProperties());
service.publishAfterCommit(BigInteger.valueOf(101));
Mockito.verify(redisTemplate).convertAndSend("easyflow:document-import:test-status", "101");
}
/**
* 验证收到 Redis 广播后会重新查询文档状态。
*
* @throws Exception 反射注入异常
*/
@Test
public void publishLocalShouldReloadDocumentStatus() throws Exception {
AtomicReference<BigInteger> selectedIdRef = new AtomicReference<BigInteger>();
DocumentImportTaskStatusStreamService service = new DocumentImportTaskStatusStreamService();
setField(service, "documentMapper", mockDocumentMapper(selectedIdRef));
setField(service, "sseThreadPool", directExecutor());
setField(service, "stringRedisTemplate", Mockito.mock(StringRedisTemplate.class));
setField(service, "statusBroadcastProperties", statusBroadcastProperties());
service.publishLocal(BigInteger.valueOf(202));
Assert.assertEquals(BigInteger.valueOf(202), selectedIdRef.get());
}
private DocumentImportStatusBroadcastProperties statusBroadcastProperties() {
DocumentImportStatusBroadcastProperties properties = new DocumentImportStatusBroadcastProperties();
properties.setStatusBroadcastChannel("easyflow:document-import:test-status");
return properties;
}
private DocumentMapper mockDocumentMapper() {
return mockDocumentMapper(new AtomicReference<BigInteger>());
}
private DocumentMapper mockDocumentMapper(AtomicReference<BigInteger> selectedIdRef) {
DocumentMapper mapper = Mockito.mock(DocumentMapper.class);
Mockito.when(mapper.selectOneById(ArgumentMatchers.any())).thenAnswer(invocation -> {
Object id = invocation.getArgument(0);
selectedIdRef.set((BigInteger) id);
Document document = new Document();
document.setId((BigInteger) id);
document.setCollectionId(BigInteger.valueOf(1));
return document;
});
return mapper;
}
private ThreadPoolTaskExecutor directExecutor() {
ThreadPoolTaskExecutor executor = Mockito.mock(ThreadPoolTaskExecutor.class);
Mockito.doAnswer(invocation -> {
Runnable runnable = invocation.getArgument(0);
runnable.run();
return null;
}).when(executor).execute(ArgumentMatchers.any(Runnable.class));
return executor;
}
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = DocumentImportTaskStatusStreamService.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}