Commit 2fea73bb by Archer Committed by GitHub

perf: index (#6131)

* perf: index

* stop design doc

* perf: stop workflow;perf: mongo connection

* fix: ts

* mq export
parent 4f95f686
...@@ -11,7 +11,7 @@ description: 'FastGPT V4.14.5 更新说明' ...@@ -11,7 +11,7 @@ description: 'FastGPT V4.14.5 更新说明'
## ⚙️ 优化 ## ⚙️ 优化
1. 优化获取 redis 所有 key 的逻辑,避免大量获取时导致阻塞。 1. 优化获取 redis 所有 key 的逻辑,避免大量获取时导致阻塞。
2. Redis 和 MQ 的重连逻辑优化。 2. MongoDB, Redis 和 MQ 的重连逻辑优化。
## 🐛 修复 ## 🐛 修复
......
...@@ -120,7 +120,7 @@ ...@@ -120,7 +120,7 @@
"document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00", "document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00",
"document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00", "document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00",
"document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00", "document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00",
"document/content/docs/upgrading/4-14/4145.mdx": "2025-12-19T00:08:30+08:00", "document/content/docs/upgrading/4-14/4145.mdx": "2025-12-20T13:11:02+08:00",
"document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00",
......
...@@ -40,6 +40,7 @@ export type ExternalProviderType = { ...@@ -40,6 +40,7 @@ export type ExternalProviderType = {
/* workflow props */ /* workflow props */
export type ChatDispatchProps = { export type ChatDispatchProps = {
res?: NextApiResponse; res?: NextApiResponse;
checkIsStopping: () => boolean;
lang?: localeType; lang?: localeType;
requestOrigin?: string; requestOrigin?: string;
mode: 'test' | 'chat' | 'debug'; mode: 'test' | 'chat' | 'debug';
...@@ -63,7 +64,7 @@ export type ChatDispatchProps = { ...@@ -63,7 +64,7 @@ export type ChatDispatchProps = {
}; };
uid: string; // Who run this workflow uid: string; // Who run this workflow
chatId?: string; chatId: string;
responseChatItemId?: string; responseChatItemId?: string;
histories: ChatItemType[]; histories: ChatItemType[];
variables: Record<string, any>; // global variable variables: Record<string, any>; // global variable
...@@ -76,7 +77,7 @@ export type ChatDispatchProps = { ...@@ -76,7 +77,7 @@ export type ChatDispatchProps = {
maxRunTimes: number; maxRunTimes: number;
isToolCall?: boolean; isToolCall?: boolean;
workflowStreamResponse?: WorkflowResponseType; workflowStreamResponse?: WorkflowResponseType;
version?: 'v1' | 'v2'; apiVersion?: 'v1' | 'v2';
workflowDispatchDeep: number; workflowDispatchDeep: number;
......
import type { OutLinkChatAuthType } from '../../../support/permission/chat/type';
import { OutLinkChatAuthSchema } from '../../../support/permission/chat/type'; import { OutLinkChatAuthSchema } from '../../../support/permission/chat/type';
import { ObjectIdSchema } from '../../../common/type/mongo'; import { ObjectIdSchema } from '../../../common/type/mongo';
import z from 'zod'; import z from 'zod';
/* ============ v2/chat/stop ============ */
export const StopV2ChatSchema = z
.object({
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
})
.meta({
example: {
appId: '1234567890',
chatId: '1234567890',
outLinkAuthData: {
shareId: '1234567890',
outLinkUid: '1234567890'
}
}
});
export type StopV2ChatParams = z.infer<typeof StopV2ChatSchema>;
export const StopV2ChatResponseSchema = z
.object({
success: z.boolean().describe('是否成功停止')
})
.meta({
example: {
success: true
}
});
export type StopV2ChatResponse = z.infer<typeof StopV2ChatResponseSchema>;
/* ============ chat file ============ */
export const PresignChatFileGetUrlSchema = z export const PresignChatFileGetUrlSchema = z
.object({ .object({
key: z.string().min(1).describe('文件key'), key: z.string().min(1).describe('文件key'),
......
...@@ -5,7 +5,12 @@ import { ChatFeedbackPath } from './feedback/index'; ...@@ -5,7 +5,12 @@ import { ChatFeedbackPath } from './feedback/index';
import { ChatHistoryPath } from './history/index'; import { ChatHistoryPath } from './history/index';
import { z } from 'zod'; import { z } from 'zod';
import { CreatePostPresignedUrlResultSchema } from '../../../../service/common/s3/type'; import { CreatePostPresignedUrlResultSchema } from '../../../../service/common/s3/type';
import { PresignChatFileGetUrlSchema, PresignChatFilePostUrlSchema } from './api'; import {
PresignChatFileGetUrlSchema,
PresignChatFilePostUrlSchema,
StopV2ChatSchema,
StopV2ChatResponseSchema
} from './api';
import { TagsMap } from '../../tag'; import { TagsMap } from '../../tag';
export const ChatPath: OpenAPIPath = { export const ChatPath: OpenAPIPath = {
...@@ -14,6 +19,31 @@ export const ChatPath: OpenAPIPath = { ...@@ -14,6 +19,31 @@ export const ChatPath: OpenAPIPath = {
...ChatFeedbackPath, ...ChatFeedbackPath,
...ChatHistoryPath, ...ChatHistoryPath,
'/v2/chat/stop': {
post: {
summary: '停止 Agent 运行',
description: `优雅停止正在运行的 Agent, 会尝试等待当前节点结束后返回,最长 5s,超过 5s 仍未结束,则会返回成功。
LLM 节点,流输出时会同时被终止,但 HTTP 请求节点这种可能长时间运行的,不会被终止。`,
tags: [TagsMap.chatPage],
requestBody: {
content: {
'application/json': {
schema: StopV2ChatSchema
}
}
},
responses: {
200: {
description: '成功停止工作流',
content: {
'application/json': {
schema: StopV2ChatResponseSchema
}
}
}
}
}
},
'/core/chat/presignChatFilePostUrl': { '/core/chat/presignChatFilePostUrl': {
post: { post: {
summary: '获取文件上传 URL', summary: '获取文件上传 URL',
......
...@@ -60,7 +60,7 @@ export function getQueue<DataType, ReturnType = void>( ...@@ -60,7 +60,7 @@ export function getQueue<DataType, ReturnType = void>(
// default error handler, to avoid unhandled exceptions // default error handler, to avoid unhandled exceptions
newQueue.on('error', (error) => { newQueue.on('error', (error) => {
addLog.error(`MQ Queue [${name}]: ${error.message}`, error); addLog.error(`MQ Queue] error`, error);
}); });
queues.set(name, newQueue); queues.set(name, newQueue);
return newQueue; return newQueue;
...@@ -76,6 +76,7 @@ export function getWorker<DataType, ReturnType = void>( ...@@ -76,6 +76,7 @@ export function getWorker<DataType, ReturnType = void>(
return worker as Worker<DataType, ReturnType>; return worker as Worker<DataType, ReturnType>;
} }
const createWorker = () => {
const newWorker = new Worker<DataType, ReturnType>(name.toString(), processor, { const newWorker = new Worker<DataType, ReturnType>(name.toString(), processor, {
connection: newWorkerRedisConnection(), connection: newWorkerRedisConnection(),
...defaultWorkerOpts, ...defaultWorkerOpts,
...@@ -85,35 +86,49 @@ export function getWorker<DataType, ReturnType = void>( ...@@ -85,35 +86,49 @@ export function getWorker<DataType, ReturnType = void>(
maxStalledCount: 3, // Move job to failed after 1 stall (default behavior) maxStalledCount: 3, // Move job to failed after 1 stall (default behavior)
...opts ...opts
}); });
// Worker is ready to process jobs (fired on initial connection and after reconnection)
newWorker.on('ready', () => {
addLog.info(`[MQ Worker] ready`, { name });
});
// default error handler, to avoid unhandled exceptions // default error handler, to avoid unhandled exceptions
newWorker.on('error', async (error) => { newWorker.on('error', async (error) => {
addLog.error(`MQ Worker error`, { addLog.error(`[MQ Worker] error`, {
message: error.message, message: error.message,
data: { name } data: { name }
}); });
await newWorker.close();
}); });
// Critical: Worker has been closed - remove from pool // Critical: Worker has been closed - remove from pool and restart
newWorker.on('closed', async () => { newWorker.on('closed', async () => {
addLog.error(`MQ Worker [${name}] closed unexpectedly`, { addLog.warn(`[MQ Worker] closed, attempting restart...`);
data: {
name, // Clean up: remove all listeners to prevent memory leaks
message: 'Worker will need to be manually restarted' newWorker.removeAllListeners();
}
}); // Retry create new worker with infinite retries
while (true) {
try { try {
// Call getWorker to create a new worker (now workers.get(name) returns undefined)
const worker = createWorker();
workers.set(name, worker);
addLog.info(`[MQ Worker] restarted successfully`);
break;
} catch (error) {
addLog.error(`[MQ Worker] failed to restart, retrying...`, error);
await delay(1000); await delay(1000);
workers.delete(name); }
getWorker(name, processor, opts); }
} catch (error) {}
}); });
newWorker.on('paused', async () => { newWorker.on('paused', async () => {
addLog.warn(`MQ Worker [${name}] paused`); addLog.warn(`[MQ Worker] paused`);
await delay(1000); await delay(1000);
newWorker.resume(); newWorker.resume();
}); });
return newWorker;
};
const newWorker = createWorker();
workers.set(name, newWorker); workers.set(name, newWorker);
return newWorker; return newWorker;
} }
......
...@@ -31,26 +31,13 @@ export async function connectMongo(props: { ...@@ -31,26 +31,13 @@ export async function connectMongo(props: {
db.set('strictQuery', 'throw'); db.set('strictQuery', 'throw');
db.connection.on('error', async (error) => { db.connection.on('error', async (error) => {
console.log('mongo error', error); console.error('mongo error', error);
try { });
if (db.connection.readyState !== 0) { db.connection.on('connected', async () => {
RemoveListeners(); console.log('mongo connected');
await db.disconnect();
await delay(1000);
await connectMongo(props);
}
} catch (error) {}
}); });
db.connection.on('disconnected', async () => { db.connection.on('disconnected', async () => {
console.log('mongo disconnected'); console.error('mongo disconnected');
try {
if (db.connection.readyState !== 0) {
RemoveListeners();
await db.disconnect();
await delay(1000);
await connectMongo(props);
}
} catch (error) {}
}); });
await db.connect(url, { await db.connect(url, {
...@@ -64,9 +51,9 @@ export async function connectMongo(props: { ...@@ -64,9 +51,9 @@ export async function connectMongo(props: {
maxIdleTimeMS: 300000, // 空闲连接超时: 5分钟,防止空闲连接长时间占用资源 maxIdleTimeMS: 300000, // 空闲连接超时: 5分钟,防止空闲连接长时间占用资源
retryWrites: true, // 重试写入: 重试写入失败的操作 retryWrites: true, // 重试写入: 重试写入失败的操作
retryReads: true, // 重试读取: 重试读取失败的操作 retryReads: true, // 重试读取: 重试读取失败的操作
serverSelectionTimeoutMS: 10000 // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞 serverSelectionTimeoutMS: 10000, // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞
heartbeatFrequencyMS: 5000 // 5s 进行一次健康检查
}); });
console.log('mongo connected');
connectedCb?.(); connectedCb?.();
......
...@@ -19,9 +19,11 @@ const REDIS_BASE_OPTION = { ...@@ -19,9 +19,11 @@ const REDIS_BASE_OPTION = {
// Reconnect on specific errors (Redis master-slave switch, network issues) // Reconnect on specific errors (Redis master-slave switch, network issues)
reconnectOnError: (err: any) => { reconnectOnError: (err: any) => {
const reconnectErrors = ['READONLY', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET']; const reconnectErrors = ['READONLY', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET'];
const shouldReconnect = reconnectErrors.some((errType) => err.message.includes(errType)); const message = typeof err?.message === 'string' ? err.message : String(err ?? '');
const shouldReconnect = reconnectErrors.some((errType) => message.includes(errType));
if (shouldReconnect) { if (shouldReconnect) {
addLog.warn(`Redis reconnecting due to error: ${err.message}`); addLog.warn(`Redis reconnecting due to error: ${message}`);
} }
return shouldReconnect; return shouldReconnect;
}, },
...@@ -37,9 +39,6 @@ export const newQueueRedisConnection = () => { ...@@ -37,9 +39,6 @@ export const newQueueRedisConnection = () => {
// Limit retries for queue operations // Limit retries for queue operations
maxRetriesPerRequest: 3 maxRetriesPerRequest: 3
}); });
redis.on('error', (error) => {
addLog.error('[Redis Queue connection error]', error);
});
return redis; return redis;
}; };
...@@ -49,9 +48,6 @@ export const newWorkerRedisConnection = () => { ...@@ -49,9 +48,6 @@ export const newWorkerRedisConnection = () => {
// BullMQ requires maxRetriesPerRequest: null for blocking operations // BullMQ requires maxRetriesPerRequest: null for blocking operations
maxRetriesPerRequest: null maxRetriesPerRequest: null
}); });
redis.on('error', (error) => {
addLog.error('[Redis Worker connection error]', error);
});
return redis; return redis;
}; };
...@@ -65,11 +61,14 @@ export const getGlobalRedisConnection = () => { ...@@ -65,11 +61,14 @@ export const getGlobalRedisConnection = () => {
maxRetriesPerRequest: 3 maxRetriesPerRequest: 3
}); });
global.redisClient.on('connect', () => {
addLog.info('[Global Redis] connected');
});
global.redisClient.on('error', (error) => { global.redisClient.on('error', (error) => {
addLog.error('[Redis Global connection error]', error); addLog.error('[Global Redis] connection error', error);
}); });
global.redisClient.on('close', () => { global.redisClient.on('close', () => {
addLog.warn('[Redis Global connection closed]'); addLog.warn('[Global Redis] connection closed');
}); });
return global.redisClient; return global.redisClient;
......
...@@ -40,7 +40,7 @@ export const addS3DelJob = async (data: S3MQJobData): Promise<void> => { ...@@ -40,7 +40,7 @@ export const addS3DelJob = async (data: S3MQJobData): Promise<void> => {
await queue.add('delete-s3-files', data, { jobId, ...jobOption }); await queue.add('delete-s3-files', data, { jobId, ...jobOption });
}; };
const prefixDel = async (bucket: S3BaseBucket, prefix: string) => { export const prefixDel = async (bucket: S3BaseBucket, prefix: string) => {
addLog.debug(`[S3 delete] delete prefix: ${prefix}`); addLog.debug(`[S3 delete] delete prefix: ${prefix}`);
let tasks: Promise<any>[] = []; let tasks: Promise<any>[] = [];
return new Promise<void>(async (resolve, reject) => { return new Promise<void>(async (resolve, reject) => {
...@@ -103,7 +103,7 @@ export const startS3DelWorker = async () => { ...@@ -103,7 +103,7 @@ export const startS3DelWorker = async () => {
} }
}, },
{ {
concurrency: 3 concurrency: 6
} }
); );
}; };
...@@ -196,6 +196,7 @@ try { ...@@ -196,6 +196,7 @@ try {
// timer, clear history // timer, clear history
ChatSchema.index({ updateTime: -1, teamId: 1 }); ChatSchema.index({ updateTime: -1, teamId: 1 });
ChatSchema.index({ teamId: 1, updateTime: -1 });
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
......
...@@ -64,6 +64,7 @@ export type ChatResponse = DispatchNodeResultType< ...@@ -64,6 +64,7 @@ export type ChatResponse = DispatchNodeResultType<
export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResponse> => { export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResponse> => {
let { let {
res, res,
checkIsStopping,
requestOrigin, requestOrigin,
stream = false, stream = false,
retainDatasetCite = true, retainDatasetCite = true,
...@@ -201,7 +202,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp ...@@ -201,7 +202,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
requestOrigin requestOrigin
}, },
userKey: externalProvider.openaiAccount, userKey: externalProvider.openaiAccount,
isAborted: () => res?.closed, isAborted: checkIsStopping,
onReasoning({ text }) { onReasoning({ text }) {
if (!aiChatReasoning) return; if (!aiChatReasoning) return;
workflowStreamResponse?.({ workflowStreamResponse?.({
......
...@@ -18,6 +18,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<RunTo ...@@ -18,6 +18,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<RunTo
const { messages, toolNodes, toolModel, childrenInteractiveParams, ...workflowProps } = props; const { messages, toolNodes, toolModel, childrenInteractiveParams, ...workflowProps } = props;
const { const {
res, res,
checkIsStopping,
requestOrigin, requestOrigin,
runtimeNodes, runtimeNodes,
runtimeEdges, runtimeEdges,
...@@ -129,7 +130,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<RunTo ...@@ -129,7 +130,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<RunTo
retainDatasetCite, retainDatasetCite,
useVision: aiChatVision useVision: aiChatVision
}, },
isAborted: () => res?.closed, isAborted: checkIsStopping,
userKey: externalProvider.openaiAccount, userKey: externalProvider.openaiAccount,
onReasoning({ text }) { onReasoning({ text }) {
if (!aiChatReasoning) return; if (!aiChatReasoning) return;
......
...@@ -59,10 +59,11 @@ import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; ...@@ -59,10 +59,11 @@ import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { i18nT } from '../../../../web/i18n/utils'; import { i18nT } from '../../../../web/i18n/utils';
import { clone } from 'lodash'; import { clone } from 'lodash';
import { validateFileUrlDomain } from '../../../common/security/fileUrlValidator'; import { validateFileUrlDomain } from '../../../common/security/fileUrlValidator';
import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus';
type Props = Omit< type Props = Omit<
ChatDispatchProps, ChatDispatchProps,
'workflowDispatchDeep' | 'timezone' | 'externalProvider' | 'cloneVariables' 'checkIsStopping' | 'workflowDispatchDeep' | 'timezone' | 'externalProvider' | 'cloneVariables'
> & { > & {
runtimeNodes: RuntimeNodeItemType[]; runtimeNodes: RuntimeNodeItemType[];
runtimeEdges: RuntimeEdgeItemType[]; runtimeEdges: RuntimeEdgeItemType[];
...@@ -87,7 +88,17 @@ export async function dispatchWorkFlow({ ...@@ -87,7 +88,17 @@ export async function dispatchWorkFlow({
concatUsage, concatUsage,
...data ...data
}: Props & WorkflowUsageProps): Promise<DispatchFlowResponse> { }: Props & WorkflowUsageProps): Promise<DispatchFlowResponse> {
const { res, stream, runningUserInfo, runningAppInfo, lastInteractive, histories, query } = data; const {
res,
stream,
runningUserInfo,
runningAppInfo,
lastInteractive,
histories,
query,
chatId,
apiVersion
} = data;
// Check url valid // Check url valid
const invalidInput = query.some((item) => { const invalidInput = query.some((item) => {
...@@ -101,6 +112,8 @@ export async function dispatchWorkFlow({ ...@@ -101,6 +112,8 @@ export async function dispatchWorkFlow({
addLog.info('[Workflow run] Invalid file url'); addLog.info('[Workflow run] Invalid file url');
return Promise.reject(new UserError('Invalid file url')); return Promise.reject(new UserError('Invalid file url'));
} }
/* Init function */
// Check point // Check point
await checkTeamAIPoints(runningUserInfo.teamId); await checkTeamAIPoints(runningUserInfo.teamId);
...@@ -120,7 +133,22 @@ export async function dispatchWorkFlow({ ...@@ -120,7 +133,22 @@ export async function dispatchWorkFlow({
}); });
} }
return usageId; return usageId;
})() })(),
// Add preview url to chat items
await addPreviewUrlToChatItems(histories, 'chatFlow'),
// Add preview url to query
...query.map(async (item) => {
if (item.type !== ChatItemValueTypeEnum.file || !item.file?.key) return;
item.file.url = await getS3ChatSource().createGetChatFileURL({
key: item.file.key,
external: true
});
}),
// Remove stopping sign
delAgentRuntimeStopSign({
appId: runningAppInfo.id,
chatId
})
]); ]);
let streamCheckTimer: NodeJS.Timeout | null = null; let streamCheckTimer: NodeJS.Timeout | null = null;
...@@ -152,16 +180,6 @@ export async function dispatchWorkFlow({ ...@@ -152,16 +180,6 @@ export async function dispatchWorkFlow({
} }
} }
// Add preview url to chat items
await addPreviewUrlToChatItems(histories, 'chatFlow');
for (const item of query) {
if (item.type !== ChatItemValueTypeEnum.file || !item.file?.key) continue;
item.file.url = await getS3ChatSource().createGetChatFileURL({
key: item.file.key,
external: true
});
}
// Get default variables // Get default variables
const cloneVariables = clone(data.variables); const cloneVariables = clone(data.variables);
const defaultVariables = { const defaultVariables = {
...@@ -173,12 +191,34 @@ export async function dispatchWorkFlow({ ...@@ -173,12 +191,34 @@ export async function dispatchWorkFlow({
timezone timezone
})) }))
}; };
// MCP
let mcpClientMemory = {} as Record<string, MCPClient>; let mcpClientMemory = {} as Record<string, MCPClient>;
// Stop sign(没有 apiVersion,说明不会有暂停)
let stopping = false;
const checkIsStopping = (): boolean => {
if (apiVersion === 'v2') {
return stopping;
}
if (apiVersion === 'v1') {
if (!res) return false;
return res.closed || !!res.errored;
}
return false;
};
const checkStoppingTimer =
apiVersion === 'v2'
? setInterval(async () => {
stopping = await shouldWorkflowStop({
appId: runningAppInfo.id,
chatId
});
}, 100)
: undefined;
// Init some props // Init some props
return runWorkflow({ return runWorkflow({
...data, ...data,
checkIsStopping,
query, query,
histories, histories,
timezone, timezone,
...@@ -189,15 +229,24 @@ export async function dispatchWorkFlow({ ...@@ -189,15 +229,24 @@ export async function dispatchWorkFlow({
concatUsage, concatUsage,
mcpClientMemory, mcpClientMemory,
cloneVariables cloneVariables
}).finally(() => { }).finally(async () => {
if (streamCheckTimer) { if (streamCheckTimer) {
clearInterval(streamCheckTimer); clearInterval(streamCheckTimer);
} }
if (checkStoppingTimer) {
clearInterval(checkStoppingTimer);
}
// Close mcpClient connections // Close mcpClient connections
Object.values(mcpClientMemory).forEach((client) => { Object.values(mcpClientMemory).forEach((client) => {
client.closeConnection(); client.closeConnection();
}); });
// 工作流完成后删除 Redis 记录
await delAgentRuntimeStopSign({
appId: runningAppInfo.id,
chatId
});
}); });
} }
...@@ -210,14 +259,14 @@ type RunWorkflowProps = ChatDispatchProps & { ...@@ -210,14 +259,14 @@ type RunWorkflowProps = ChatDispatchProps & {
}; };
export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowResponse> => { export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowResponse> => {
let { let {
res, apiVersion,
checkIsStopping,
runtimeNodes = [], runtimeNodes = [],
runtimeEdges = [], runtimeEdges = [],
histories = [], histories = [],
variables = {}, variables = {},
externalProvider, externalProvider,
retainDatasetCite = true, retainDatasetCite = true,
version = 'v1',
responseDetail = true, responseDetail = true,
responseAllData = true, responseAllData = true,
usageId, usageId,
...@@ -328,10 +377,6 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR ...@@ -328,10 +377,6 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
}); });
} }
get connectionIsActive(): boolean {
return !res?.closed && !res?.errored;
}
// Add active node to queue (if already in the queue, it will not be added again) // Add active node to queue (if already in the queue, it will not be added again)
addActiveNode(nodeId: string) { addActiveNode(nodeId: string) {
if (this.activeRunQueue.has(nodeId)) { if (this.activeRunQueue.has(nodeId)) {
...@@ -585,7 +630,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR ...@@ -585,7 +630,7 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
})(); })();
// Response node response // Response node response
if (version === 'v2' && !data.isToolCall && isRootRuntime && formatResponseData) { if (apiVersion === 'v2' && !data.isToolCall && isRootRuntime && formatResponseData) {
data.workflowStreamResponse?.({ data.workflowStreamResponse?.({
event: SseResponseEventEnum.flowNodeResponse, event: SseResponseEventEnum.flowNodeResponse,
data: responseAllData data: responseAllData
...@@ -813,8 +858,8 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR ...@@ -813,8 +858,8 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
}); });
return; return;
} }
if (!this.connectionIsActive) { if (checkIsStopping()) {
addLog.warn('Request is closed/errored', { addLog.warn('Workflow stopped', {
appId: data.runningAppInfo.id, appId: data.runningAppInfo.id,
nodeId: node.nodeId, nodeId: node.nodeId,
nodeName: node.name nodeName: node.name
......
import { addLog } from '../../../common/system/log';
import { getGlobalRedisConnection } from '../../../common/redis/index';
import { delay } from '@fastgpt/global/common/system/utils';
const WORKFLOW_STATUS_PREFIX = 'agent_runtime_stopping';
const TTL = 60; // 1分钟
export const StopStatus = 'STOPPING';
export type WorkflowStatusParams = {
appId: string;
chatId: string;
};
// 获取工作流状态键
export const getRuntimeStatusKey = (params: WorkflowStatusParams): string => {
return `${WORKFLOW_STATUS_PREFIX}:${params.appId}:${params.chatId}`;
};
// 暂停任务
export const setAgentRuntimeStop = async (params: WorkflowStatusParams): Promise<void> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
await redis.set(key, 1, 'EX', TTL);
};
// 删除任务状态
export const delAgentRuntimeStopSign = async (params: WorkflowStatusParams): Promise<void> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
await redis.del(key).catch((err) => {
addLog.error(`[Agent Runtime Stop] Delete stop sign error`, err);
});
};
// 检查工作流是否应该停止
export const shouldWorkflowStop = (params: WorkflowStatusParams): Promise<boolean> => {
const redis = getGlobalRedisConnection();
const key = getRuntimeStatusKey(params);
return redis
.get(key)
.then((res) => !!res)
.catch(() => false);
};
/**
* 等待工作流完成(记录被删除)
* @param params 工作流参数
* @param timeout 超时时间(毫秒),默认5秒
* @param pollInterval 轮询间隔(毫秒),默认50毫秒
* @returns true=正常完成, false=超时
*/
export const waitForWorkflowComplete = async ({
appId,
chatId,
timeout = 5000,
pollInterval = 50
}: {
appId: string;
chatId: string;
timeout?: number;
pollInterval?: number;
}) => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const sign = await shouldWorkflowStop({ appId, chatId });
// 如果没有暂停中的标志,则认为已经完成任务了。
if (!sign) {
return;
}
// 等待下一次轮询
await delay(pollInterval);
}
return;
};
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
"clear_input_value": "清空输入", "clear_input_value": "清空输入",
"click_contextual_preview": "点击查看上下文预览", "click_contextual_preview": "点击查看上下文预览",
"click_to_add_url": "输入文件链接", "click_to_add_url": "输入文件链接",
"completion_finish_close": "连接断开", "completion_finish_close": "请求关闭",
"completion_finish_content_filter": "触发安全风控", "completion_finish_content_filter": "触发安全风控",
"completion_finish_function_call": "函数调用", "completion_finish_function_call": "函数调用",
"completion_finish_length": "超出回复限制", "completion_finish_length": "超出回复限制",
......
...@@ -19,6 +19,8 @@ import { useFileUpload } from '../hooks/useFileUpload'; ...@@ -19,6 +19,8 @@ import { useFileUpload } from '../hooks/useFileUpload';
import ComplianceTip from '@/components/common/ComplianceTip/index'; import ComplianceTip from '@/components/common/ComplianceTip/index';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import VoiceInput, { type VoiceInputComponentRef } from './VoiceInput'; import VoiceInput, { type VoiceInputComponentRef } from './VoiceInput';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { postStopV2Chat } from '@/web/core/chat/api';
const InputGuideBox = dynamic(() => import('./InputGuideBox')); const InputGuideBox = dynamic(() => import('./InputGuideBox'));
...@@ -124,6 +126,19 @@ const ChatInput = ({ ...@@ -124,6 +126,19 @@ const ChatInput = ({
}, },
[TextareaDom, canSendMessage, fileList, onSendMessage, replaceFiles] [TextareaDom, canSendMessage, fileList, onSendMessage, replaceFiles]
); );
const { runAsync: handleStop, loading: isStopping } = useRequest2(async () => {
try {
if (isChatting) {
await postStopV2Chat({
appId,
chatId,
outLinkAuthData
}).catch();
}
} finally {
onStop();
}
});
const RenderTextarea = useMemo( const RenderTextarea = useMemo(
() => ( () => (
...@@ -329,7 +344,9 @@ const ChatInput = ({ ...@@ -329,7 +344,9 @@ const ChatInput = ({
{/* Send Button Container */} {/* Send Button Container */}
<Flex alignItems={'center'} w={[8, 9]} h={[8, 9]} borderRadius={'lg'}> <Flex alignItems={'center'} w={[8, 9]} h={[8, 9]} borderRadius={'lg'}>
<Flex <MyBox
isLoading={isStopping}
display={'flex'}
alignItems={'center'} alignItems={'center'}
justifyContent={'center'} justifyContent={'center'}
w={[7, 9]} w={[7, 9]}
...@@ -343,7 +360,7 @@ const ChatInput = ({ ...@@ -343,7 +360,7 @@ const ChatInput = ({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (isChatting) { if (isChatting) {
return onStop(); return handleStop();
} }
return handleSend(); return handleSend();
}} }}
...@@ -355,7 +372,7 @@ const ChatInput = ({ ...@@ -355,7 +372,7 @@ const ChatInput = ({
<MyIcon name={'core/chat/sendFill'} {...iconSize} color={'white'} /> <MyIcon name={'core/chat/sendFill'} {...iconSize} color={'white'} />
</MyTooltip> </MyTooltip>
)} )}
</Flex> </MyBox>
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>
...@@ -370,12 +387,13 @@ const ChatInput = ({ ...@@ -370,12 +387,13 @@ const ChatInput = ({
whisperConfig?.open, whisperConfig?.open,
inputValue, inputValue,
t, t,
isStopping,
isChatting, isChatting,
canSendMessage, canSendMessage,
onOpenSelectFile, onOpenSelectFile,
onSelectFile, onSelectFile,
handleSend, handleSend,
onStop handleStop
]); ]);
const activeStyles: FlexProps = { const activeStyles: FlexProps = {
......
...@@ -432,10 +432,10 @@ const ChatBox = ({ ...@@ -432,10 +432,10 @@ const ChatBox = ({
}, [questionGuide, appId, chatId, outLinkAuthData, scrollToBottom]); }, [questionGuide, appId, chatId, outLinkAuthData, scrollToBottom]);
/* Abort chat completions, questionGuide */ /* Abort chat completions, questionGuide */
const abortRequest = useMemoizedFn((signal: string = 'stop') => { const abortRequest = useMemoizedFn((reason: string = 'stop') => {
chatController.current?.abort(signal); chatController.current?.abort(new Error(reason));
questionGuideController.current?.abort(signal); questionGuideController.current?.abort(new Error(reason));
pluginController.current?.abort(signal); pluginController.current?.abort(new Error(reason));
}); });
/** /**
...@@ -463,8 +463,7 @@ const ChatBox = ({ ...@@ -463,8 +463,7 @@ const ChatBox = ({
} }
// Abort the previous request // Abort the previous request
abortRequest(); questionGuideController.current?.abort(new Error('stop'));
questionGuideController.current?.abort('stop');
text = text.trim(); text = text.trim();
...@@ -605,8 +604,9 @@ const ChatBox = ({ ...@@ -605,8 +604,9 @@ const ChatBox = ({
newChatHistories = state.map((item, index) => { newChatHistories = state.map((item, index) => {
if (index !== state.length - 1) return item; if (index !== state.length - 1) return item;
// Check node response error
const responseData = mergeChatResponseData(item.responseData || []); const responseData = mergeChatResponseData(item.responseData || []);
// Check node response error
if (!abortSignal?.signal?.aborted) {
const err = const err =
responseData[responseData.length - 1]?.error || responseData[responseData.length - 1]?.error ||
responseData[responseData.length - 1]?.errorText; responseData[responseData.length - 1]?.errorText;
...@@ -616,6 +616,7 @@ const ChatBox = ({ ...@@ -616,6 +616,7 @@ const ChatBox = ({
status: 'warning' status: 'warning'
}); });
} }
}
return { return {
...item, ...item,
...@@ -1184,7 +1185,7 @@ const ChatBox = ({ ...@@ -1184,7 +1185,7 @@ const ChatBox = ({
) : ( ) : (
<ChatInput <ChatInput
onSendMessage={sendPrompt} onSendMessage={sendPrompt}
onStop={() => chatController.current?.abort('stop')} onStop={() => abortRequest('stop')}
TextareaDom={TextareaDom} TextareaDom={TextareaDom}
resetInputVal={resetInputVal} resetInputVal={resetInputVal}
chatForm={chatForm} chatForm={chatForm}
...@@ -1206,7 +1207,7 @@ const ChatBox = ({ ...@@ -1206,7 +1207,7 @@ const ChatBox = ({
<ChatInput <ChatInput
onSendMessage={sendPrompt} onSendMessage={sendPrompt}
onStop={() => chatController.current?.abort('stop')} onStop={() => abortRequest('stop')}
TextareaDom={TextareaDom} TextareaDom={TextareaDom}
resetInputVal={resetInputVal} resetInputVal={resetInputVal}
chatForm={chatForm} chatForm={chatForm}
......
...@@ -181,6 +181,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -181,6 +181,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
/* start process */ /* start process */
const { flowResponses, assistantResponses, system_memories, newVariables, durationSeconds } = const { flowResponses, assistantResponses, system_memories, newVariables, durationSeconds } =
await dispatchWorkFlow({ await dispatchWorkFlow({
apiVersion: 'v2',
res, res,
lang: getLocale(req), lang: getLocale(req),
requestOrigin: req.headers.origin, requestOrigin: req.headers.origin,
...@@ -209,7 +210,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -209,7 +210,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
stream: true, stream: true,
maxRunTimes: WORKFLOW_MAX_RUN_TIMES, maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
workflowStreamResponse: workflowResponseWrite, workflowStreamResponse: workflowResponseWrite,
version: 'v2',
responseDetail: true responseDetail: true
}); });
......
...@@ -11,7 +11,7 @@ import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants ...@@ -11,7 +11,7 @@ import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants
import { getLastInteractiveValue } from '@fastgpt/global/core/workflow/runtime/utils'; import { getLastInteractiveValue } from '@fastgpt/global/core/workflow/runtime/utils';
import { getLocale } from '@fastgpt/service/common/middle/i18n'; import { getLocale } from '@fastgpt/service/common/middle/i18n';
import { createChatUsageRecord } from '@fastgpt/service/support/wallet/usage/controller'; import { createChatUsageRecord } from '@fastgpt/service/support/wallet/usage/controller';
import { clone } from 'lodash'; import { getNanoid } from '@fastgpt/global/common/string/tools';
async function handler( async function handler(
req: NextApiRequest, req: NextApiRequest,
...@@ -73,6 +73,7 @@ async function handler( ...@@ -73,6 +73,7 @@ async function handler(
tmbId: app.tmbId tmbId: app.tmbId
}, },
runningUserInfo: await getRunningUserInfoByTmbId(tmbId), runningUserInfo: await getRunningUserInfoByTmbId(tmbId),
chatId: getNanoid(),
runtimeNodes: nodes, runtimeNodes: nodes,
runtimeEdges: edges, runtimeEdges: edges,
defaultSkipNodeQueue: skipNodeQueue, defaultSkipNodeQueue: skipNodeQueue,
......
...@@ -278,6 +278,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -278,6 +278,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
showNodeStatus showNodeStatus
}); });
const saveChatId = chatId || getNanoid(24);
/* start flow controller */ /* start flow controller */
const { const {
flowResponses, flowResponses,
...@@ -289,6 +291,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -289,6 +291,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} = await (async () => { } = await (async () => {
if (app.version === 'v2') { if (app.version === 'v2') {
return dispatchWorkFlow({ return dispatchWorkFlow({
apiVersion: 'v1',
res, res,
lang: getLocale(req), lang: getLocale(req),
requestOrigin: req.headers.origin, requestOrigin: req.headers.origin,
...@@ -304,7 +307,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -304,7 +307,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
runningUserInfo: await getRunningUserInfoByTmbId(tmbId), runningUserInfo: await getRunningUserInfoByTmbId(tmbId),
uid: String(outLinkUserId || tmbId), uid: String(outLinkUserId || tmbId),
chatId, chatId: saveChatId,
responseChatItemId, responseChatItemId,
runtimeNodes, runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(edges, interactive), runtimeEdges: storeEdges2RuntimeEdges(edges, interactive),
...@@ -351,7 +354,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -351,7 +354,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
memories: system_memories memories: system_memories
}; };
const saveChatId = chatId || getNanoid(24);
const params: SaveChatProps = { const params: SaveChatProps = {
chatId: saveChatId, chatId: saveChatId,
appId: app._id, appId: app._id,
......
...@@ -278,6 +278,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -278,6 +278,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
showNodeStatus showNodeStatus
}); });
const saveChatId = chatId || getNanoid(24);
/* start flow controller */ /* start flow controller */
const { const {
flowResponses, flowResponses,
...@@ -289,6 +291,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -289,6 +291,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} = await (async () => { } = await (async () => {
if (app.version === 'v2') { if (app.version === 'v2') {
return dispatchWorkFlow({ return dispatchWorkFlow({
apiVersion: 'v2',
res, res,
lang: getLocale(req), lang: getLocale(req),
requestOrigin: req.headers.origin, requestOrigin: req.headers.origin,
...@@ -304,7 +307,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -304,7 +307,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
runningUserInfo: await getRunningUserInfoByTmbId(tmbId), runningUserInfo: await getRunningUserInfoByTmbId(tmbId),
uid: String(outLinkUserId || tmbId), uid: String(outLinkUserId || tmbId),
chatId, chatId: saveChatId,
responseChatItemId, responseChatItemId,
runtimeNodes, runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(edges, interactive), runtimeEdges: storeEdges2RuntimeEdges(edges, interactive),
...@@ -317,7 +320,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -317,7 +320,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
retainDatasetCite, retainDatasetCite,
maxRunTimes: WORKFLOW_MAX_RUN_TIMES, maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
workflowStreamResponse: workflowResponseWrite, workflowStreamResponse: workflowResponseWrite,
version: 'v2',
responseAllData, responseAllData,
responseDetail responseDetail
}); });
...@@ -354,7 +356,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ...@@ -354,7 +356,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
memories: system_memories memories: system_memories
}; };
const saveChatId = chatId || getNanoid(24);
const params: SaveChatProps = { const params: SaveChatProps = {
chatId: saveChatId, chatId: saveChatId,
appId: app._id, appId: app._id,
......
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import {
setAgentRuntimeStop,
waitForWorkflowComplete
} from '@fastgpt/service/core/workflow/dispatch/workflowStatus';
import { StopV2ChatSchema, type StopV2ChatResponse } from '@fastgpt/global/openapi/core/chat/api';
async function handler(req: NextApiRequest, res: NextApiResponse): Promise<StopV2ChatResponse> {
const { appId, chatId, outLinkAuthData } = StopV2ChatSchema.parse(req.body);
await authChatCrud({
req,
authToken: true,
authApiKey: true,
appId,
chatId,
...outLinkAuthData
});
// 设置停止状态
await setAgentRuntimeStop({
appId,
chatId
});
// 等待工作流完成 (最多等待 5 秒)
await waitForWorkflowComplete({ appId, chatId, timeout: 5000 });
return {
success: true
};
}
export default NextAPI(handler);
...@@ -24,6 +24,7 @@ import type { ...@@ -24,6 +24,7 @@ import type {
UpdateFavouriteAppParamsType UpdateFavouriteAppParamsType
} from '@fastgpt/global/openapi/core/chat/favourite/api'; } from '@fastgpt/global/openapi/core/chat/favourite/api';
import type { ChatFavouriteAppType } from '@fastgpt/global/core/chat/favouriteApp/type'; import type { ChatFavouriteAppType } from '@fastgpt/global/core/chat/favouriteApp/type';
import type { StopV2ChatParams } from '@fastgpt/global/openapi/core/chat/api';
/** /**
* 获取初始化聊天内容 * 获取初始化聊天内容
...@@ -76,3 +77,6 @@ export const updateFavouriteAppTags = (data: { id: string; tags: string[] }[]) = ...@@ -76,3 +77,6 @@ export const updateFavouriteAppTags = (data: { id: string; tags: string[] }[]) =
export const deleteFavouriteApp = (data: { id: string }) => export const deleteFavouriteApp = (data: { id: string }) =>
DELETE<null>('/proApi/core/chat/setting/favourite/delete', data); DELETE<null>('/proApi/core/chat/setting/favourite/delete', data);
/* Chat controller */
export const postStopV2Chat = (data: StopV2ChatParams) => POST('/v2/chat/stop', data);
...@@ -13,7 +13,7 @@ import { getUser } from '@test/datas/users'; ...@@ -13,7 +13,7 @@ import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request'; import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest'; import { describe, expect, it, beforeEach } from 'vitest';
describe.sequential('closeCustom api test', () => { describe('closeCustom api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>; let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string; let appId: string;
let chatId: string; let chatId: string;
......
...@@ -13,7 +13,7 @@ import { getUser } from '@test/datas/users'; ...@@ -13,7 +13,7 @@ import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request'; import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest'; import { describe, expect, it, beforeEach } from 'vitest';
describe.sequential('updateFeedbackReadStatus api test', () => { describe('updateFeedbackReadStatus api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>; let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string; let appId: string;
let chatId: string; let chatId: string;
......
...@@ -14,7 +14,7 @@ import { getUser } from '@test/datas/users'; ...@@ -14,7 +14,7 @@ import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request'; import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest'; import { describe, expect, it, beforeEach } from 'vitest';
describe.sequential('updateUserFeedback api test', () => { describe('updateUserFeedback api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>; let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string; let appId: string;
let chatId: string; let chatId: string;
......
...@@ -3,6 +3,7 @@ import type { Model, Schema } from 'mongoose'; ...@@ -3,6 +3,7 @@ import type { Model, Schema } from 'mongoose';
import { Mongoose } from 'mongoose'; import { Mongoose } from 'mongoose';
export const MONGO_URL = process.env.MONGODB_URI ?? ''; export const MONGO_URL = process.env.MONGODB_URI ?? '';
const maxConnecting = Math.max(30, Number(process.env.DB_MAX_LINK || 20));
declare global { declare global {
var mongodb: Mongoose | undefined; var mongodb: Mongoose | undefined;
...@@ -52,49 +53,30 @@ export async function connectMongo(db: Mongoose, url: string): Promise<Mongoose> ...@@ -52,49 +53,30 @@ export async function connectMongo(db: Mongoose, url: string): Promise<Mongoose>
db.connection.removeAllListeners('disconnected'); db.connection.removeAllListeners('disconnected');
db.set('strictQuery', 'throw'); db.set('strictQuery', 'throw');
db.connection.on('error', async (error: any) => { db.connection.on('error', async (error) => {
addLog.error('mongo error', error); console.error('mongo error', error);
try { });
if (db.connection.readyState !== 0) { db.connection.on('connected', async () => {
await db.disconnect(); console.log('mongo connected');
await delay(1000);
await connectMongo(db, url);
}
} catch (_error) {
addLog.error('Error during reconnection:', _error);
}
}); });
db.connection.on('disconnected', async () => { db.connection.on('disconnected', async () => {
addLog.warn('mongo disconnected'); console.error('mongo disconnected');
try {
if (db.connection.readyState !== 0) {
await db.disconnect();
await delay(1000);
await connectMongo(db, url);
}
} catch (_error) {
addLog.error('Error during reconnection:', _error);
}
}); });
const options = { await db.connect(url, {
bufferCommands: true, bufferCommands: true,
maxPoolSize: Math.max(30, Number(process.env.MONGO_MAX_LINK || 20)), maxConnecting: maxConnecting, // 最大连接数: 防止连接数过多时无法满足需求
minPoolSize: 20, maxPoolSize: maxConnecting, // 最大连接池大小: 防止连接池过大时无法满足需求
connectTimeoutMS: 60000, minPoolSize: 20, // 最小连接数: 20,防止连接数过少时无法满足需求
waitQueueTimeoutMS: 60000, connectTimeoutMS: 60000, // 连接超时: 60秒,防止连接失败时长时间阻塞
socketTimeoutMS: 60000, waitQueueTimeoutMS: 60000, // 等待队列超时: 60秒,防止等待队列长时间阻塞
maxIdleTimeMS: 300000, socketTimeoutMS: 60000, // Socket 超时: 60秒,防止Socket连接失败时长时间阻塞
retryWrites: true, maxIdleTimeMS: 300000, // 空闲连接超时: 5分钟,防止空闲连接长时间占用资源
retryReads: true, retryWrites: true, // 重试写入: 重试写入失败的操作
serverSelectionTimeoutMS: 60000, retryReads: true, // 重试读取: 重试读取失败的操作
heartbeatFrequencyMS: 20000, serverSelectionTimeoutMS: 10000, // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞
maxStalenessSeconds: 120 heartbeatFrequencyMS: 5000 // 5s 进行一次健康检查
}; });
await db.connect(url, options);
addLog.info('mongo connected');
return db; return db;
} catch (error) { } catch (error) {
addLog.error('Mongo connect error', error); addLog.error('Mongo connect error', error);
......
import { describe, test, expect, beforeEach } from 'vitest';
import {
setAgentRuntimeStop,
delAgentRuntimeStopSign,
shouldWorkflowStop,
waitForWorkflowComplete
} from '@fastgpt/service/core/workflow/dispatch/workflowStatus';
describe('Workflow Status Redis Functions', () => {
const testAppId = 'test_app_123';
const testChatId = 'test_chat_456';
beforeEach(async () => {
// 清理测试数据
await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId });
});
test('should set stopping sign', async () => {
await setAgentRuntimeStop({
appId: testAppId,
chatId: testChatId
});
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(true);
});
test('should return false for non-existent status', async () => {
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(false);
});
test('should detect stopping status', async () => {
await setAgentRuntimeStop({
appId: testAppId,
chatId: testChatId
});
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(true);
});
test('should return false after deleting stop sign', async () => {
await setAgentRuntimeStop({
appId: testAppId,
chatId: testChatId
});
await delAgentRuntimeStopSign({
appId: testAppId,
chatId: testChatId
});
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(false);
});
test('should wait for workflow completion', async () => {
// 设置初始停止标志
await setAgentRuntimeStop({
appId: testAppId,
chatId: testChatId
});
// 模拟异步完成(删除停止标志)
setTimeout(async () => {
await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId });
}, 500);
// 等待完成,waitForWorkflowComplete 现在是 void 返回
await waitForWorkflowComplete({
appId: testAppId,
chatId: testChatId,
timeout: 2000
});
// 验证停止标志已被删除
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(false);
});
test('should timeout when waiting too long', async () => {
await setAgentRuntimeStop({
appId: testAppId,
chatId: testChatId
});
// 等待超时(不删除标志)
await waitForWorkflowComplete({
appId: testAppId,
chatId: testChatId,
timeout: 100
});
// 验证停止标志仍然存在
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(true);
});
test('should delete workflow stop sign', async () => {
await setAgentRuntimeStop({
appId: testAppId,
chatId: testChatId
});
await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId });
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(false);
});
test('should handle concurrent stop sign operations', async () => {
// 并发设置停止标志
await Promise.all([
setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }),
setAgentRuntimeStop({ appId: testAppId, chatId: testChatId })
]);
// 停止标志应该存在
const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId });
expect(shouldStop).toBe(true);
});
});
import { vi } from 'vitest'; import { vi } from 'vitest';
// In-memory storage for mock Redis
const createRedisStorage = () => {
const storage = new Map<string, any>();
const expiryMap = new Map<string, number>();
// Check and remove expired keys
const isExpired = (key: string): boolean => {
const expiry = expiryMap.get(key);
if (expiry && expiry < Date.now()) {
storage.delete(key);
expiryMap.delete(key);
return true;
}
return false;
};
return {
get: (key: string) => {
if (isExpired(key)) return null;
return storage.get(key) ?? null;
},
set: (key: string, value: any, exMode?: string, exValue?: number) => {
storage.set(key, value);
// Handle EX (seconds) and PX (milliseconds) options
if (exMode === 'EX' && typeof exValue === 'number') {
expiryMap.set(key, Date.now() + exValue * 1000);
} else if (exMode === 'PX' && typeof exValue === 'number') {
expiryMap.set(key, Date.now() + exValue);
}
return 'OK';
},
del: (...keys: string[]) => {
let deletedCount = 0;
keys.forEach((key) => {
if (storage.has(key)) {
storage.delete(key);
expiryMap.delete(key);
deletedCount++;
}
});
return deletedCount;
},
exists: (...keys: string[]) => {
let count = 0;
keys.forEach((key) => {
if (!isExpired(key) && storage.has(key)) count++;
});
return count;
},
clear: () => {
storage.clear();
expiryMap.clear();
}
};
};
// Create a comprehensive mock Redis client factory // Create a comprehensive mock Redis client factory
const createMockRedisClient = () => ({ const createMockRedisClient = () => {
const redisStorage = createRedisStorage();
return {
// Connection methods // Connection methods
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
connect: vi.fn().mockResolvedValue(undefined), connect: vi.fn().mockResolvedValue(undefined),
...@@ -11,11 +70,19 @@ const createMockRedisClient = () => ({ ...@@ -11,11 +70,19 @@ const createMockRedisClient = () => ({
return createMockRedisClient(); return createMockRedisClient();
}), }),
// Key-value operations // Key-value operations with actual storage
get: vi.fn().mockResolvedValue(null), get: vi.fn().mockImplementation((key: string) => Promise.resolve(redisStorage.get(key))),
set: vi.fn().mockResolvedValue('OK'), set: vi
del: vi.fn().mockResolvedValue(1), .fn()
exists: vi.fn().mockResolvedValue(0), .mockImplementation((key: string, value: any, exMode?: string, exValue?: number) =>
Promise.resolve(redisStorage.set(key, value, exMode, exValue))
),
del: vi
.fn()
.mockImplementation((...keys: string[]) => Promise.resolve(redisStorage.del(...keys))),
exists: vi
.fn()
.mockImplementation((...keys: string[]) => Promise.resolve(redisStorage.exists(...keys))),
keys: vi.fn().mockResolvedValue([]), keys: vi.fn().mockResolvedValue([]),
scan: vi.fn().mockImplementation((cursor) => { scan: vi.fn().mockImplementation((cursor) => {
// 模拟多次迭代的场景 // 模拟多次迭代的场景
...@@ -66,8 +133,101 @@ const createMockRedisClient = () => ({ ...@@ -66,8 +133,101 @@ const createMockRedisClient = () => ({
del: vi.fn().mockReturnThis(), del: vi.fn().mockReturnThis(),
unlink: vi.fn().mockReturnThis(), unlink: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue([]) exec: vi.fn().mockResolvedValue([])
})) })),
});
// Internal storage for testing purposes
_storage: redisStorage
};
};
// Shared global Redis storage for all mock clients
const globalRedisStorage = createRedisStorage();
// Create mock client with shared storage
const createSharedMockRedisClient = () => {
return {
// Connection methods
on: vi.fn().mockReturnThis(),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
quit: vi.fn().mockResolvedValue('OK'),
duplicate: vi.fn(function (this: any) {
return createSharedMockRedisClient();
}),
// Key-value operations with shared storage
get: vi.fn().mockImplementation((key: string) => Promise.resolve(globalRedisStorage.get(key))),
set: vi
.fn()
.mockImplementation((key: string, value: any, exMode?: string, exValue?: number) =>
Promise.resolve(globalRedisStorage.set(key, value, exMode, exValue))
),
del: vi
.fn()
.mockImplementation((...keys: string[]) => Promise.resolve(globalRedisStorage.del(...keys))),
exists: vi
.fn()
.mockImplementation((...keys: string[]) =>
Promise.resolve(globalRedisStorage.exists(...keys))
),
keys: vi.fn().mockResolvedValue([]),
scan: vi.fn().mockImplementation((cursor) => {
if (cursor === '0') return ['100', ['key1', 'key2']];
if (cursor === '100') return ['0', ['key3']];
return ['0', []];
}),
// Hash operations
hget: vi.fn().mockResolvedValue(null),
hset: vi.fn().mockResolvedValue(1),
hdel: vi.fn().mockResolvedValue(1),
hgetall: vi.fn().mockResolvedValue({}),
hmset: vi.fn().mockResolvedValue('OK'),
// Expiry operations
expire: vi.fn().mockResolvedValue(1),
ttl: vi.fn().mockResolvedValue(-1),
expireat: vi.fn().mockResolvedValue(1),
// Increment operations
incr: vi.fn().mockResolvedValue(1),
decr: vi.fn().mockResolvedValue(1),
incrby: vi.fn().mockResolvedValue(1),
decrby: vi.fn().mockResolvedValue(1),
incrbyfloat: vi.fn().mockResolvedValue(1),
// Server commands
info: vi.fn().mockResolvedValue(''),
ping: vi.fn().mockResolvedValue('PONG'),
flushdb: vi.fn().mockImplementation(() => {
globalRedisStorage.clear();
return Promise.resolve('OK');
}),
// List operations
lpush: vi.fn().mockResolvedValue(1),
rpush: vi.fn().mockResolvedValue(1),
lpop: vi.fn().mockResolvedValue(null),
rpop: vi.fn().mockResolvedValue(null),
llen: vi.fn().mockResolvedValue(0),
// Set operations
sadd: vi.fn().mockResolvedValue(1),
srem: vi.fn().mockResolvedValue(1),
smembers: vi.fn().mockResolvedValue([]),
sismember: vi.fn().mockResolvedValue(0),
// pipeline
pipeline: vi.fn(() => ({
del: vi.fn().mockReturnThis(),
unlink: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue([])
})),
// Internal storage for testing purposes
_storage: globalRedisStorage
};
};
// Mock Redis connections to prevent connection errors in tests // Mock Redis connections to prevent connection errors in tests
vi.mock('@fastgpt/service/common/redis', async (importOriginal) => { vi.mock('@fastgpt/service/common/redis', async (importOriginal) => {
...@@ -75,20 +235,20 @@ vi.mock('@fastgpt/service/common/redis', async (importOriginal) => { ...@@ -75,20 +235,20 @@ vi.mock('@fastgpt/service/common/redis', async (importOriginal) => {
return { return {
...actual, ...actual,
newQueueRedisConnection: vi.fn(createMockRedisClient), newQueueRedisConnection: vi.fn(createSharedMockRedisClient),
newWorkerRedisConnection: vi.fn(createMockRedisClient), newWorkerRedisConnection: vi.fn(createSharedMockRedisClient),
getGlobalRedisConnection: vi.fn(() => { getGlobalRedisConnection: vi.fn(() => {
if (!global.mockRedisClient) { if (!global.mockRedisClient) {
global.mockRedisClient = createMockRedisClient(); global.mockRedisClient = createSharedMockRedisClient();
} }
return global.mockRedisClient; return global.mockRedisClient;
}), }),
initRedisClient: vi.fn().mockResolvedValue(createMockRedisClient()) initRedisClient: vi.fn().mockResolvedValue(createSharedMockRedisClient())
}; };
}); });
// Initialize global.redisClient with mock before any module imports it // Initialize global.redisClient with mock before any module imports it
// This prevents getGlobalRedisConnection() from creating a real Redis client // This prevents getGlobalRedisConnection() from creating a real Redis client
if (!global.redisClient) { if (!global.redisClient) {
global.redisClient = createMockRedisClient() as any; global.redisClient = createSharedMockRedisClient() as any;
} }
...@@ -49,8 +49,6 @@ beforeEach(async () => { ...@@ -49,8 +49,6 @@ beforeEach(async () => {
onTestFinished(async () => { onTestFinished(async () => {
clean(); clean();
// Wait for any ongoing transactions and operations to complete
await delay(500);
// Ensure all sessions are closed before dropping database // Ensure all sessions are closed before dropping database
try { try {
...@@ -62,9 +60,6 @@ beforeEach(async () => { ...@@ -62,9 +60,6 @@ beforeEach(async () => {
// Ignore errors during cleanup // Ignore errors during cleanup
console.warn('Error during test cleanup:', error); console.warn('Error during test cleanup:', error);
} }
// Additional delay to prevent lock contention between tests
await delay(100);
}); });
}); });
......
...@@ -20,8 +20,10 @@ export default defineConfig({ ...@@ -20,8 +20,10 @@ export default defineConfig({
outputFile: 'test-results.json', outputFile: 'test-results.json',
setupFiles: 'test/setup.ts', setupFiles: 'test/setup.ts',
globalSetup: 'test/globalSetup.ts', globalSetup: 'test/globalSetup.ts',
// fileParallelism: false, // File-level execution: serial (one file at a time to avoid MongoDB conflicts)
maxConcurrency: 5, fileParallelism: false,
// Test-level execution within a file: parallel (up to 5 concurrent tests)
maxConcurrency: 10,
pool: 'threads', pool: 'threads',
include: [ include: [
'test/test.ts', 'test/test.ts',
...@@ -31,6 +33,7 @@ export default defineConfig({ ...@@ -31,6 +33,7 @@ export default defineConfig({
'projects/marketplace/test/**/*.test.ts' 'projects/marketplace/test/**/*.test.ts'
], ],
testTimeout: 20000, testTimeout: 20000,
hookTimeout: 30000,
reporters: ['github-actions', 'default'] reporters: ['github-actions', 'default']
} }
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment