Skip to content

Commit dde1bdb

Browse files
committed
feat: Cross thread cancellation of workflow
1 parent 427c72d commit dde1bdb

4 files changed

Lines changed: 122 additions & 13 deletions

File tree

‎apps/application/workflow/workflow_run_registry.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
# coding=utf-8
22
"""
3-
@project: MaxKB
4-
@file: workflow_run_registry.py
5-
@desc: 工作流运行注册表,用于管理和取消正在运行的工作流实例
3+
@project: MaxKB
4+
@file: workflow_run_registry.py
5+
@desc: 工作流运行注册表,用于管理和取消正在运行的工作流实例
66
"""
7+
78
import threading
89
from enum import Enum
910

1011
from common.utils.logger import maxkb_logger
12+
from common.bus import on
1113

1214

1315
class CancelResult(Enum):
1416
"""取消操作结果"""
17+
1518
CANCELLED = "CANCELLED"
1619
NOT_FOUND = "NOT_FOUND"
1720
FAILED = "FAILED"
@@ -166,3 +169,6 @@ def running_ids(cls) -> list:
166169
"""
167170
with cls._lock:
168171
return list(cls._running.keys())
172+
173+
174+
on("cancel_chat", lambda args: WorkflowRunRegistry.cancel_by_chat_id(args.get("chat_id")))

‎apps/chat/views/v3/chat.py‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from rest_framework.parsers import MultiPartParser
2020
from rest_framework.request import Request
2121
from rest_framework.views import APIView
22-
22+
from common.bus import emit
2323
from application.api.application_api import SpeechToTextAPI, TextToSpeechAPI
2424
from application.models import ChatUserType, ChatSourceChoices, ApplicationAccessToken, ChatRecord
2525
from chat.api.chat_api import ChatAPI
@@ -285,15 +285,8 @@ class CancelWorkflowView(APIView):
285285
tags=[_("V3 Chat")], # type: ignore
286286
)
287287
def post(self, request: Request, chat_id: str):
288-
from application.workflow.workflow_run_registry import WorkflowRunRegistry, CancelResult
289-
290-
result_enum = WorkflowRunRegistry.cancel_by_chat_id(chat_id)
291-
if result_enum == CancelResult.CANCELLED:
292-
return result.success({"status": "cancelled", "chat_id": chat_id})
293-
elif result_enum == CancelResult.NOT_FOUND:
294-
return result.success({"status": "not_found", "chat_id": chat_id})
295-
else:
296-
return result.fail(500, _("Failed to cancel workflow"))
288+
emit("cancel_chat", {"chat_id": str(chat_id)})
289+
return result.success({"status": "cancelled", "chat_id": chat_id})
297290

298291

299292
class ResumeStreamView(APIView):

‎apps/common/bus/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from common.bus.message_bus import on, emit

‎apps/common/bus/message_bus.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# coding=utf-8
2+
"""
3+
@project: MaxKB
4+
@file: message_bus.py
5+
@desc: 基于 Redis Pub/Sub 的跨进程、跨系统消息总线。提供 emit / on 两个接口。
6+
"""
7+
8+
import json
9+
import threading
10+
from typing import Callable, Dict, List
11+
12+
from common.utils.logger import maxkb_logger
13+
14+
CHANNEL = "maxkb:bus"
15+
16+
_handlers: Dict[str, List[Callable]] = {}
17+
_handlers_lock = threading.Lock()
18+
19+
_subscriber_started = False
20+
_subscriber_thread = None
21+
_subscriber_lock = threading.Lock()
22+
23+
24+
def _redis_client():
25+
"""取底层 redis-py 客户端(含 pubsub 能力),已由 Django settings 配置支持 sentinel/普通 Redis。"""
26+
from django_redis import get_redis_connection
27+
28+
return get_redis_connection("default")
29+
30+
31+
def _start_subscriber() -> None:
32+
"""懒启动:每个进程只起一条 daemon 订阅线程;线程因建连失败退出后,下次调用会重新拉起。"""
33+
global _subscriber_started, _subscriber_thread
34+
if _subscriber_started and _subscriber_thread is not None and _subscriber_thread.is_alive():
35+
return
36+
with _subscriber_lock:
37+
if _subscriber_started and _subscriber_thread is not None and _subscriber_thread.is_alive():
38+
return
39+
_subscriber_started = True
40+
_subscriber_thread = threading.Thread(target=_listen, name="maxkb-bus", daemon=True)
41+
_subscriber_thread.start()
42+
43+
44+
def _listen() -> None:
45+
# 建连失败(Redis 不可用)则记日志退出,不自动重连;由 emit 检测线程存活后重新拉起。
46+
# 建连成功后的消息循环由 redis-py 内部维持,无需自行重连。
47+
ps = None
48+
try:
49+
ps = _redis_client().pubsub(ignore_subscribe_messages=True)
50+
ps.subscribe(CHANNEL)
51+
for message in ps.listen():
52+
_dispatch(message)
53+
except Exception as e:
54+
maxkb_logger.error(f"Message bus subscriber failed: {e}")
55+
finally:
56+
if ps is not None:
57+
try:
58+
ps.close()
59+
except Exception:
60+
pass
61+
62+
63+
def _dispatch(message) -> None:
64+
try:
65+
data = message.get("data")
66+
if isinstance(data, (bytes, bytearray)):
67+
data = data.decode("utf-8")
68+
body = json.loads(data)
69+
event = body.get("event")
70+
if not event:
71+
return
72+
args = body.get("args")
73+
for handler in list(_handlers.get(event, ())):
74+
try:
75+
handler(args)
76+
except Exception as e:
77+
maxkb_logger.error(f"Message bus handler error for event[{event}]: {e}")
78+
except Exception as e:
79+
maxkb_logger.error("Failed to dispatch message bus event: %s", e)
80+
81+
82+
def on(event: str, handler: Callable) -> None:
83+
"""
84+
注册一个事件监听,本进程内生效;收到该事件时异步调用 handler(args)。
85+
:param event: 事件名称
86+
:param handler: 回调函数 def handler(args):
87+
pass
88+
"""
89+
with _handlers_lock:
90+
_handlers.setdefault(event, []).append(handler)
91+
_start_subscriber()
92+
93+
94+
def emit(event: str, args=None) -> bool:
95+
"""
96+
向所有进程广播一个事件。Redis 不可用时返回 False。
97+
:param event: 事件名称
98+
:param args: 事件参数
99+
"""
100+
_start_subscriber()
101+
try:
102+
_redis_client().publish(
103+
CHANNEL,
104+
json.dumps({"event": event, "args": args}, ensure_ascii=False),
105+
)
106+
return True
107+
except Exception as e:
108+
maxkb_logger.warning(f"Message bus emit[{event}] failed: {e}")
109+
return False

0 commit comments

Comments
 (0)